From b835b03008e9dbe2f69e2cbbb8bbd614dcd09fe5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 5 Jan 2024 19:38:24 +0000 Subject: [PATCH 1/4] feat(site): add healthcheck page for provisioner daemons --- codersdk/health.go | 1 + site/src/AppRouter.tsx | 7 + site/src/pages/HealthPage/HealthLayout.tsx | 1 + .../ProvisionerDaemonsPage.stories.tsx | 16 ++ .../HealthPage/ProvisionerDaemonsPage.tsx | 165 ++++++++++++++++++ ...ies.tsx => WorkspaceProxyPage.stories.tsx} | 0 site/src/testHelpers/entities.ts | 41 ++++- 7 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/HealthPage/ProvisionerDaemonsPage.stories.tsx create mode 100644 site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx rename site/src/pages/HealthPage/{WebsocketProxyPage.stories.tsx => WorkspaceProxyPage.stories.tsx} (100%) diff --git a/codersdk/health.go b/codersdk/health.go index a53ca73192ef9..a54b65762efea 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -26,6 +26,7 @@ var HealthSections = []HealthSection{ HealthSectionWebsocket, HealthSectionDatabase, HealthSectionWorkspaceProxy, + HealthSectionProvisionerDaemons, } type HealthSettings struct { diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 5d19dd3b88725..245e263586d73 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -234,6 +234,9 @@ const WebsocketPage = lazy(() => import("./pages/HealthPage/WebsocketPage")); const WorkspaceProxyHealthPage = lazy( () => import("./pages/HealthPage/WorkspaceProxyPage"), ); +const ProvisionerDaemonsHealthPage = lazy( + () => import("./pages/HealthPage/ProvisionerDaemonsPage"), +); export const AppRouter: FC = () => { return ( @@ -400,6 +403,10 @@ export const AppRouter: FC = () => { path="workspace-proxy" element={} /> + } + /> {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit diff --git a/site/src/pages/HealthPage/HealthLayout.tsx b/site/src/pages/HealthPage/HealthLayout.tsx index 0b80dc01b423b..0aef42e457991 100644 --- a/site/src/pages/HealthPage/HealthLayout.tsx +++ b/site/src/pages/HealthPage/HealthLayout.tsx @@ -34,6 +34,7 @@ export function HealthLayout() { websocket: "Websocket", database: "Database", workspace_proxy: "Workspace Proxy", + provisioner_daemons: "Provisioner Daemons", } as const; const visibleSections = filterVisibleSections(sections); diff --git a/site/src/pages/HealthPage/ProvisionerDaemonsPage.stories.tsx b/site/src/pages/HealthPage/ProvisionerDaemonsPage.stories.tsx new file mode 100644 index 0000000000000..7117aa886967e --- /dev/null +++ b/site/src/pages/HealthPage/ProvisionerDaemonsPage.stories.tsx @@ -0,0 +1,16 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage"; +import { generateMeta } from "./storybook"; + +const meta: Meta = { + title: "pages/Health/ProvisionerDaemons", + ...generateMeta({ + path: "/health/provisioner-daemons", + element: , + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx new file mode 100644 index 0000000000000..417f0d933be22 --- /dev/null +++ b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx @@ -0,0 +1,165 @@ +import { Header, HeaderTitle, HealthyDot, Main, Pill } from "./Content"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; +import { useTheme } from "@mui/material/styles"; +import { DismissWarningButton } from "./DismissWarningButton"; +import { Alert } from "components/Alert/Alert"; +import { HealthcheckReport } from "api/typesGenerated"; +import { createDayString } from "utils/createDayString"; + +import { useOutletContext } from "react-router-dom"; +import Business from "@mui/icons-material/Business"; +import Person from "@mui/icons-material/Person"; +import SwapHoriz from "@mui/icons-material/SwapHoriz"; +import Tooltip from "@mui/material/Tooltip"; +import Sell from "@mui/icons-material/Sell"; + +export const ProvisionerDaemonsPage = () => { + const healthStatus = useOutletContext(); + const { provisioner_daemons } = healthStatus; + const theme = useTheme(); + return ( + <> + + {pageTitle("Provisioner Daemons - Health")} + + +
+ + + Provisioner Daemons + + +
+ +
+ {provisioner_daemons.warnings.map((warning) => { + return ( + + {warning.message} + + ); + })} + + {provisioner_daemons.items.map(({ provisioner_daemon, warnings }) => { + const daemonScope = + provisioner_daemon.tags["scope"] || "organization"; + const iconScope = + daemonScope === "organization" ? : ; + const extraTags = Object.keys(provisioner_daemon.tags) + .filter((key) => key !== "scope" && key !== "owner") + .reduce( + (acc, key) => { + acc[key] = provisioner_daemon.tags[key]; + return acc; + }, + {} as Record, + ); + const isWarning = warnings.length > 0; + return ( +
+
+
+
+

+ {provisioner_daemon.name} +

+ + {provisioner_daemon.version} + +
+
+
+ + }> + {provisioner_daemon.api_version} + + + + {titleCase(daemonScope)} + + {Object.keys(extraTags).map((k) => ( + + }> + {extraTags[k]} + + + ))} +
+
+ +
+ {warnings.length > 0 ? ( +
+ {warnings.map((warning, i) => ( + {warning.message} + ))} +
+ ) : ( + No warnings + )} + {provisioner_daemon.last_seen_at && ( + + Last seen {createDayString(provisioner_daemon.last_seen_at)} + + )} +
+
+ ); + })} +
+ + ); +}; + +const titleCase = (s: string): string => { + return s.charAt(0).toLocaleUpperCase() + s.slice(1); +}; + +export default ProvisionerDaemonsPage; diff --git a/site/src/pages/HealthPage/WebsocketProxyPage.stories.tsx b/site/src/pages/HealthPage/WorkspaceProxyPage.stories.tsx similarity index 100% rename from site/src/pages/HealthPage/WebsocketProxyPage.stories.tsx rename to site/src/pages/HealthPage/WorkspaceProxyPage.stories.tsx diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dc2d107802a2d..1f303f4ee881f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3103,7 +3103,16 @@ export const MockHealth: TypesGen.HealthcheckReport = { }, provisioner_daemons: { severity: "ok", - warnings: [], + warnings: [ + { + message: "Something is wrong!", + code: "EUNKNOWN", + }, + { + message: "This is also bad.", + code: "EPD01", + }, + ], dismissed: false, items: [ { @@ -3111,17 +3120,43 @@ export const MockHealth: TypesGen.HealthcheckReport = { id: "e455b582-ac04-4323-9ad6-ab71301fa006", created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", - name: "vvuurrkk-2", - version: "v2.6.0-devel+965ad5e96", + name: "ok", + version: "v2.3.4-devel+abcd1234", api_version: "1.0", provisioners: ["echo", "terraform"], tags: { owner: "", scope: "organization", + custom_tag_name: "custom_tag_value", }, }, warnings: [], }, + { + provisioner_daemon: { + id: "e455b582-ac04-4323-9ad6-ab71301fa006", + created_at: "2024-01-04T15:53:03.21563Z", + last_seen_at: "2024-01-04T16:05:03.967551Z", + name: "unhappy", + version: "v0.0.1", + api_version: "0.1", + provisioners: ["echo", "terraform"], + tags: { + owner: "", + scope: "organization", + }, + }, + warnings: [ + { + message: "Something specific is wrong with this daemon.", + code: "EUNKNOWN", + }, + { + message: "And now for something completely different.", + code: "EUNKNOWN", + }, + ], + }, ], }, coder_version: "v2.5.0-devel+5fad61102", From fa52dc32172e5d1edf16f0cc30fee7549ef35747 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 8 Jan 2024 15:52:10 +0000 Subject: [PATCH 2/4] update docs --- docs/admin/healthcheck.md | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/admin/healthcheck.md b/docs/admin/healthcheck.md index a85d6f50ec70b..62a7de61973f4 100644 --- a/docs/admin/healthcheck.md +++ b/docs/admin/healthcheck.md @@ -267,6 +267,54 @@ _One or more Workspace Proxies Unhealthy_ **Solution:** Ensure that Coder can establish a connection to the configured workspace proxies. +### EPD01 + +_No Provisioner Daemons Available_ + +**Problem:** No provisioner daemons are registered with Coder. No workspaces can +be built until there is at least one provisioner daemon running. + +**Solution:** + +If you are using +[External Provisioner Daemons](./provisioners.md#external-provisioners), ensure +that they are able to successfully connect to Coder. Otherwise, ensure +[`--provisioner-daemons`](../cli/server.md#provisioner-daemons) is set to a +value greater than 0. + +> Note: This may be a transient issue if you are currently in the process of +> updating your deployment. + +### EPD02 + +_Provisioner Daemon Version Mismatch_ + +**Problem:** One or more provisioner daemons are more than one major or minor +version out of date with the main deployment. It is important that provisioner +daemons are updated at the same time as the main deployment to minimize the risk +of API incompatibility. + +**Solution:** Update the provisioner daemon to match the currently running +version of Coder. + +> Note: This may be a transient issue if you are currently in the process of +> updating your deployment. + +### EPD03 + +_Provisioner Daemon API Version Mismatch_ + +**Problem:** One or more provisioner daemons are using APIs that are marked as +deprecated. These deprecated APIs may be removed in a future release of Coder, +at which point the affected provisioner daemons will no longer be able to +connect to Coder. + +**Solution:** Update the provisioner daemon to match the currently running +version of Coder. + +> Note: This may be a transient issue if you are currently in the process of +> updating your deployment. + ## EUNKNOWN _Unknown Error_ From e91093dbbeea0e124bfd2bdbe8a6e3bddd583e6e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 8 Jan 2024 16:06:55 +0000 Subject: [PATCH 3/4] robustify titleCase() --- .../HealthPage/ProvisionerDaemonsPage.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx index 417f0d933be22..747a7a1330c15 100644 --- a/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx @@ -16,7 +16,7 @@ import Sell from "@mui/icons-material/Sell"; export const ProvisionerDaemonsPage = () => { const healthStatus = useOutletContext(); - const { provisioner_daemons } = healthStatus; + const { provisioner_daemons: daemons } = healthStatus; const theme = useTheme(); return ( <> @@ -26,14 +26,14 @@ export const ProvisionerDaemonsPage = () => {
- + Provisioner Daemons
- {provisioner_daemons.warnings.map((warning) => { + {daemons.warnings.map((warning) => { return ( {warning.message} @@ -41,16 +41,16 @@ export const ProvisionerDaemonsPage = () => { ); })} - {provisioner_daemons.items.map(({ provisioner_daemon, warnings }) => { + {daemons.items.map(({ provisioner_daemon: daemon, warnings }) => { const daemonScope = - provisioner_daemon.tags["scope"] || "organization"; + daemon.tags["scope"] || "organization"; const iconScope = daemonScope === "organization" ? : ; - const extraTags = Object.keys(provisioner_daemon.tags) + const extraTags = Object.keys(daemon.tags) .filter((key) => key !== "scope" && key !== "owner") .reduce( (acc, key) => { - acc[key] = provisioner_daemon.tags[key]; + acc[key] = daemon.tags[key]; return acc; }, {} as Record, @@ -58,7 +58,7 @@ export const ProvisionerDaemonsPage = () => { const isWarning = warnings.length > 0; return (
{ >

- {provisioner_daemon.name} + {daemon.name}

- {provisioner_daemon.version} + {daemon.version}
@@ -105,7 +105,7 @@ export const ProvisionerDaemonsPage = () => { > }> - {provisioner_daemon.api_version} + {daemon.api_version} @@ -141,12 +141,12 @@ export const ProvisionerDaemonsPage = () => { ) : ( No warnings )} - {provisioner_daemon.last_seen_at && ( + {daemon.last_seen_at && ( - Last seen {createDayString(provisioner_daemon.last_seen_at)} + Last seen {createDayString(daemon.last_seen_at)} )} @@ -158,8 +158,15 @@ export const ProvisionerDaemonsPage = () => { ); }; -const titleCase = (s: string): string => { - return s.charAt(0).toLocaleUpperCase() + s.slice(1); -}; +const titleCase = (s: string) : string => { + switch (s.length) { + case 0: + return ""; + case 1: + return s.toLocaleUpperCase(); + default: + return s.charAt(0).toLocaleUpperCase() + s.slice(1); + } +} export default ProvisionerDaemonsPage; From 9dcc3e60cac9d58768074caa8bb95e2ee90d2e5c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 8 Jan 2024 16:37:56 +0000 Subject: [PATCH 4/4] do not forget that CSS exists and is magic --- .../HealthPage/ProvisionerDaemonsPage.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx index 747a7a1330c15..6c797b9815f38 100644 --- a/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/HealthPage/ProvisionerDaemonsPage.tsx @@ -42,8 +42,7 @@ export const ProvisionerDaemonsPage = () => { })} {daemons.items.map(({ provisioner_daemon: daemon, warnings }) => { - const daemonScope = - daemon.tags["scope"] || "organization"; + const daemonScope = daemon.tags["scope"] || "organization"; const iconScope = daemonScope === "organization" ? : ; const extraTags = Object.keys(daemon.tags) @@ -87,9 +86,7 @@ export const ProvisionerDaemonsPage = () => { }} >
-

- {daemon.name} -

+

{daemon.name}

{daemon.version} @@ -109,7 +106,15 @@ export const ProvisionerDaemonsPage = () => { - {titleCase(daemonScope)} + + + {daemonScope} + + {Object.keys(extraTags).map((k) => ( @@ -158,15 +163,4 @@ export const ProvisionerDaemonsPage = () => { ); }; -const titleCase = (s: string) : string => { - switch (s.length) { - case 0: - return ""; - case 1: - return s.toLocaleUpperCase(); - default: - return s.charAt(0).toLocaleUpperCase() + s.slice(1); - } -} - export default ProvisionerDaemonsPage;