8000 feat: Add support for update checks and notifications by mafredri · Pull Request #4810 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: Add support for update checks and notifications #4810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Dec 1, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add authorization, dismissal and fetch after login
  • Loading branch information
mafredri 8000 committed Nov 30, 2022
commit 81d11dd01ce0e6e54778a038cbbd0107b9f083c3
6 changes: 4 additions & 2 deletions site/src/components/AlertBanner/AlertBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import { AlertBannerCtas } from "./AlertBannerCtas"
* @param text: default text to be displayed to the user; useful for warnings or as a fallback error message
* @param error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
* @param actions: an array of CTAs passed in by the consumer
* @param dismissible: determines whether or not the banner should have a `Dismiss` CTA
* @param retry: a handler to retry the action that spawned the error
* @param dismissible: determines whether or not the banner should have a `Dismiss` CTA
* @param onDismiss: a handler that is called when the `Dismiss` CTA is clicked, after the animation has finished
*/
export const AlertBanner: FC<React.PropsWithChildren<AlertBannerProps>> = ({
children,
Expand All @@ -27,6 +28,7 @@ export const AlertBanner: FC<React.PropsWithChildren<AlertBannerProps>> = ({
actions = [],
retry,
dismissible = false,
onDismiss,
}) => {
const { t } = useTranslation("common")

Expand All @@ -50,7 +52,7 @@ export const AlertBanner: FC<React.PropsWithChildren<AlertBannerProps>> = ({
const [showDetails, setShowDetails] = useState(false)

return (
<Collapse in={open}>
<Collapse in={open} onExited={() => onDismiss && onDismiss()}>
<Stack
className={classes.alertContainer}
direction="row"
Expand Down
1 change: 1 addition & 0 deletions site/src/components/AlertBanner/alertTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export interface AlertBannerProps {
error?: ApiError | Error | unknown
actions?: ReactElement[]
dismissible?: boolean
onDismiss?: () => void
retry?: () => void
}
42 changes: 30 additions & 12 deletions site/src/components/AuthAndFrame/AuthAndFrame.tsx
8000
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { makeStyles } from "@material-ui/core/styles"
import { useActor } from "@xstate/react"
import { Loader } from "components/Loader/Loader"
import { FC, Suspense, useContext } from "react"
import { FC, Suspense, useContext, useEffect } from "react"
import { XServiceContext } from "../../xServices/StateContext"
import { Footer } from "../Footer/Footer"
import { Navbar } from "../Navbar/Navbar"
Expand All @@ -19,20 +19,35 @@ interface AuthAndFrameProps {
export const AuthAndFrame: FC<AuthAndFrameProps> = ({ children }) => {
const styles = useStyles({})
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const [updateCheckState] = useActor(xServices.updateCheckXService)
const [updateCheckState, updateCheckSend] = useActor(
xServices.updateCheckXService,
)

useEffect(() => {
if (authState.matches("signedIn")) {
updateCheckSend("CHECK")
} else {
updateCheckSend("CLEAR")
}
}, [authState, updateCheckSend])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review(site): I'm not super happy with how this turned out. I kind of wanted to represent this in the updateCheckXService, but I couldn't figure out how to do inter-machine dependencies.

This snippet here enables e.g. the following scenario:

  1. User is loggedin as non-owner
  2. User logs out
  3. User logs in as owner
  4. Update notification is shown

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, inter-machine dependencies are tricky; I'm not sure if there's a better way. @presleyp do you have any workarounds? I think you looked at something like this recently with pagination.


return (
<RequireAuth>
<div className={styles.site}>
<Navbar />
<div className={styles.siteBanner}>
<Margins>
<UpdateCheckBanner
updateCheck={updateCheckState.context.updateCheck}
/>
</Margins>
</div>
{updateCheckState.context.show && (
<div className={styles.updateCheckBanner}>
<Margins>
<UpdateCheckBanner
updateCheck={updateCheckState.context.updateCheck}
error={updateCheckState.context.error}
onDismiss={() => updateCheckSend("DISMISS")}
Copy link
Member Author
@mafredri mafredri Nov 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review(site): Dismissal as any user (practically only an owner) will prevent notification from showing up until browser window is reloaded, even if logged in/out in-between. This seems like acceptable behavior (managed via XState service).

/>
</Margins>
</div>
)}
<div className={styles.siteContent}>
<Suspense fallback={<Loader />}>{children}</Suspense>
</div>
Expand All @@ -48,12 +63,15 @@ const useStyles = makeStyles((theme) => ({
minHeight: "100vh",
flexDirection: "column",
},
siteBanner: {
updateCheckBanner: {
// Add spacing at the top and remove some from the bottom. Removal
// is necessary to avoid a visual jerk when the banner is dismissed.
// It also give a more pleasant distance to the site content when
// the banner is visible.
marginTop: theme.spacing(2),
marginBottom: -theme.spacing(2),
},
siteContent: {
flex: 1,
// Accommodate for banner margin since it is dismissible.
marginTop: -theme.spacing(2),
},
}))
17 changes: 14 additions & 3 deletions site/src/components/UpdateCheckBanner/UpdateCheckBanner.tsx
A3E2
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import * as TypesGen from "api/typesGenerated"

export interface UpdateCheckBannerProps {
updateCheck?: TypesGen.UpdateCheckResponse
error?: Error | unknown
onDismiss?: () => void
}

export const UpdateCheckBanner: React.FC<
React.PropsWithChildren<UpdateCheckBannerProps>
> = ({ updateCheck }) => {
> = ({ updateCheck, error, onDismiss }) => {
const { t } = useTranslation("common")

return (
<>
{updateCheck && !updateCheck.current && (
<AlertBanner severity="info" dismissible>
{!error && updateCheck && !updateCheck.current && (
<AlertBanner severity="info" onDismiss={onDismiss} dismissible>
<div>
<Trans
t={t}
Expand All @@ -32,6 +34,15 @@ export const UpdateCheckBanner: React.FC<
</div>
</AlertBanner>
)}
{error && (
<AlertBanner
severity="error"
error={error}
text={t("updateCheck.error")}
onDismiss={onDismiss}
dismissible
/>
)}
</>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious why we throw an error if the check fails. Perhaps it would be better if this failed silently (we could throw something in the terminal if we wanted)? Maybe not, just a thought after seeing the error on local:
Screen Shot 2022-11-30 at 5 02 28 PM

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it and I worried we wouldn't notice if it stops working for whatever reason. I can make it silent or add a prefix (Update check failed: Route not found.). Ideally we would keep it silent and log it to Sentry or a similar bug tracker.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, maybe we could just add a console.error since we don't have Sentry.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored this now, it looks like this: https://www.chromatic.com/test?appId=624de63c6aacee003aa84340&id=6388bfa22e0a55f2ff0c1f1e

I'm still game to remove it if that's preferred. 👍🏻

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mafredri nope looks good!

)
}
3 changes: 2 additions & 1 deletion site/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"select": "Select emoji"
},
"updateCheck": {
"message": "Coder {{version}} is now available. View the <4>release notes</4> and <7>upgrade instructions</7> for more information."
"message": "Coder {{version}} is now available. View the <4>release notes</4> and <7>upgrade instructions</7> for more information.",
"error": "Coder update check failed."
}
}
147 changes: 117 additions & 30 deletions site/src/xServices/updateCheck/updateCheckXService.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,172 @@
import { assign, createMachine } from "xstate"
import * as API from "api/api"
import * as TypesGen from "api/typesGenerated"
import { checkAuthorization, getUpdateCheck } from "api/api"
import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"

export const Language = {
updateAvailable: "New version available",
updateAvailableMessage: (
version: string,
url: string,
upgrade_instructions_url: string,
): string =>
`Coder ${version} is now available at ${url}. See ${upgrade_instructions_url} for information on how to upgrade.`,
}
export const checks = {
viewUpdateCheck: "viewUpdateCheck",
} as const

export const permissionsToCheck = {
[checks.viewUpdateCheck]: {
object: {
resource_type: "update_check",
},
action: "read",
},
} as const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why we need to export these constants or why we need to cast as const although I see this pattern in our other machines. @presley (or @mafredri) is there something dumb I'm missing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, I thought it was weird but I kept them just in case. I'll remove them and see if something breaks 👍🏻


export type Permissions = Record<keyof typeof permissionsToCheck, boolean>

export interface UpdateCheckContext {
getUpdateCheckError?: Error | unknown
updateCheck?: TypesGen.UpdateCheckResponse
show: boolean
updateCheck?: UpdateCheckResponse
permissions?: Permissions
error?: Error | unknown
}

export type UpdateCheckEvent =
| { type: "CHECK" }
| { type: "CLEAR" }
| { type: "DISMISS" }

export const updateCheckMachine = createMachine(
{
id: "updateCheckState",
predictableActionArguments: true,
tsTypes: {} as import("./updateCheckXService.typegen").Typegen0,
schema: {
context: {} as UpdateCheckContext,
events: {} as UpdateCheckEvent,
services: {} as {
checkPermissions: {
data: AuthorizationResponse
}
getUpdateCheck: {
data: TypesGen.UpdateCheckResponse
data: UpdateCheckResponse
}
},
},
context: {
updateCheck: undefined,
show: false,
},
initial: "gettingUpdateCheck",
initial: "idle",
states: {
gettingUpdateCheck: {
idle: {
on: {
CHECK: {
target: "fetchingPermissions",
},
},
},
fetchingPermissions: {
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
target: "checkingPermissions",
},
],
onError: [
{
actions: ["assignError"],
target: "show",
},
],
},
},
checkingPermissions: {
always: [
{
target: "fetchingUpdateCheck",
cond: "canViewUpdateCheck",
},
{
target: "dismissOrClear",
cond: "canNotViewUpdateCheck",
},
],
},
fetchingUpdateCheck: {
invoke: {
src: "getUpdateCheck",
id: "getUpdateCheck",
onDone: [
{
actions: ["assignUpdateCheck", "clearGetUpdateCheckError"],
target: "#updateCheckState.success",
actions: ["assignUpdateCheck", "clearError"],
target: "show",
},
],
onError: [
{
actions: ["assignGetUpdateCheckError", "clearUpdateCheck"],
target: "#updateCheckState.failure",
actions: ["assignError", "clearUpdateCheck"],
target: "show",
},
],
},
},
success: {
type: "final",
show: {
entry: "assignShow",
always: [
{
target: "dismissOrClear",
},
],
},
dismissOrClear: {
on: {
DISMISS: {
actions: ["assignHide"],
target: "dismissed",
},
CLEAR: {
actions: ["clearUpdateCheck", "clearError", "assignHide"],
target: "idle",
},
},
},
failure: {
dismissed: {
type: "final",
},
},
},
{
services: {
getUpdateCheck: API.getUpdateCheck,
checkPermissions: async () =>
checkAuthorization({ checks: permissionsToCheck }),
getUpdateCheck: getUpdateCheck,
},
actions: {
assignPermissions: assign({
permissions: (_, event) => event.data as Permissions,
}),
assignShow: assign({
show: true,
}),
assignHide: assign({
show: false,
}),
assignUpdateCheck: assign({
updateCheck: (_, event) => event.data,
}),
clearUpdateCheck: assign((context: UpdateCheckContext) => ({
clearUpdateCheck: assign((context) => ({
...context,
updateCheck: undefined,
})),
assignGetUpdateCheckError: assign({
getUpdateCheckError: (_, event) => event.data,
assignError: assign({
error: (_, event) => event.data,
}),
clearGetUpdateCheckError: assign((context: UpdateCheckContext) => ({
clearError: assign((context) => ({
...context,
getUpdateCheckError: undefined,
error: undefined,
})),
},
guards: {
canViewUpdateCheck: (context) =>
context.permissions?.[checks.viewUpdateCheck] || false,
canNotViewUpdateCheck: (context) =>
!context.permissions?.[checks.viewUpdateCheck],
},
},
)
0