From 96e9a4f85ce259516cfb2a7631094512ac6a026e Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Fri, 20 Sep 2024 09:55:04 -0600 Subject: [PATCH] feat(site): add warnings and status indicator to provisioner groups (#14708) --- cli/organizationsettings.go | 8 +- site/src/api/api.ts | 12 ++ site/src/api/queries/organizations.ts | 13 ++ .../StatusIndicator.stories.tsx | 63 +++++++ .../UserDropdown/UserDropdown.stories.tsx | 2 +- .../modules/provisioners/ProvisionerGroup.tsx | 154 +++++++++++------- .../OrganizationProvisionersPage.tsx | 55 +------ ...ganizationProvisionersPageView.stories.tsx | 85 ++++++---- .../OrganizationProvisionersPageView.tsx | 98 +++++++---- site/src/testHelpers/entities.ts | 24 ++- 10 files changed, 331 insertions(+), 183 deletions(-) create mode 100644 site/src/components/StatusIndicator/StatusIndicator.stories.tsx diff --git a/cli/organizationsettings.go b/cli/organizationsettings.go index c4422c8d37..2c6b901de1 100644 --- a/cli/organizationsettings.go +++ b/cli/organizationsettings.go @@ -125,13 +125,13 @@ func (r *RootCmd) setOrganizationSettings(orgContext *OrganizationContext, setti settingJSON, err := json.Marshal(output) if err != nil { - return fmt.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err) + return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err) } var dst bytes.Buffer err = json.Indent(&dst, settingJSON, "", "\t") if err != nil { - return fmt.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err) + return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err) } _, err = fmt.Fprintln(inv.Stdout, dst.String()) @@ -190,13 +190,13 @@ func (r *RootCmd) printOrganizationSetting(orgContext *OrganizationContext, sett settingJSON, err := json.Marshal(output) if err != nil { - return fmt.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err) + return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err) } var dst bytes.Buffer err = json.Indent(&dst, settingJSON, "", "\t") if err != nil { - return fmt.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err) + return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err) } _, err = fmt.Fprintln(inv.Stdout, dst.String()) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f337dfb13b..e0781846ff 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -692,6 +692,18 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + */ + getProvisionerDaemonGroupsByOrganization = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organization}/provisionerkeys/daemons`, + ); + return response.data; + }; + getTemplate = async (templateId: string): Promise => { const response = await this.axios.get( `/api/v2/templates/${templateId}`, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index bca34d896d..8e1143800b 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -128,6 +128,19 @@ export const provisionerDaemons = (organization: string) => { }; }; +export const getProvisionerDaemonGroupsKey = (organization: string) => [ + "organization", + organization, + "provisionerDaemons", +]; + +export const provisionerDaemonGroups = (organization: string) => { + return { + queryKey: getProvisionerDaemonGroupsKey(organization), + queryFn: () => API.getProvisionerDaemonGroupsByOrganization(organization), + }; +}; + /** * Fetch permissions for a single organization. * diff --git a/site/src/components/StatusIndicator/StatusIndicator.stories.tsx b/site/src/components/StatusIndicator/StatusIndicator.stories.tsx new file mode 100644 index 0000000000..f3da964dbd --- /dev/null +++ b/site/src/components/StatusIndicator/StatusIndicator.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatusIndicator } from "./StatusIndicator"; + +const meta: Meta = { + title: "components/StatusIndicator", + component: StatusIndicator, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + args: { + color: "success", + }, +}; + +export const SuccessOutline: Story = { + args: { + color: "success", + variant: "outlined", + }, +}; + +export const Warning: Story = { + args: { + color: "warning", + }, +}; + +export const WarningOutline: Story = { + args: { + color: "warning", + variant: "outlined", + }, +}; + +export const Danger: Story = { + args: { + color: "danger", + }, +}; + +export const DangerOutline: Story = { + args: { + color: "danger", + variant: "outlined", + }, +}; + +export const Inactive: Story = { + args: { + color: "inactive", + }, +}; + +export const InactiveOutline: Story = { + args: { + color: "inactive", + variant: "outlined", + }, +}; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index b6b6baf0c4..ff35d79807 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -31,7 +31,7 @@ const Example: Story = { await step("click to open", async () => { await userEvent.click(canvas.getByRole("button")); await waitFor(() => - expect(screen.getByText(/v2\.99\.99/i)).toBeInTheDocument(), + expect(screen.getByText(/v2\.\d+\.\d+/i)).toBeInTheDocument(), ); }); }, diff --git a/site/src/modules/provisioners/ProvisionerGroup.tsx b/site/src/modules/provisioners/ProvisionerGroup.tsx index 33487199f3..6954660902 100644 --- a/site/src/modules/provisioners/ProvisionerGroup.tsx +++ b/site/src/modules/provisioners/ProvisionerGroup.tsx @@ -20,6 +20,7 @@ import { PopoverTrigger, } from "components/Popover/Popover"; import { Stack } from "components/Stack/Stack"; +import { StatusIndicator } from "components/StatusIndicator/StatusIndicator"; import { type FC, useState } from "react"; import { createDayString } from "utils/createDayString"; import { docs } from "utils/docs"; @@ -31,7 +32,7 @@ interface ProvisionerGroupProps { readonly buildInfo?: BuildInfoResponse; readonly keyName?: string; readonly type: ProvisionerGroupType; - readonly provisioners: ProvisionerDaemon[]; + readonly provisioners: readonly ProvisionerDaemon[]; } export const ProvisionerGroup: FC = ({ @@ -40,36 +41,65 @@ export const ProvisionerGroup: FC = ({ type, provisioners, }) => { - const [provisioner] = provisioners; const theme = useTheme(); const [showDetails, setShowDetails] = useState(false); - const daemonScope = provisioner.tags.scope || "organization"; - const iconScope = daemonScope === "organization" ? : ; + const firstProvisioner = provisioners[0]; + if (!firstProvisioner) { + return null; + } - const provisionerVersion = provisioner.version; + const daemonScope = firstProvisioner.tags.scope || "organization"; const allProvisionersAreSameVersion = provisioners.every( - (provisioner) => provisioner.version === provisionerVersion, + (it) => it.version === firstProvisioner.version, ); - const upToDate = - allProvisionersAreSameVersion && buildInfo?.version === provisioner.version; + const provisionerVersion = allProvisionersAreSameVersion + ? firstProvisioner.version + : null; const provisionerCount = provisioners.length === 1 ? "1 provisioner" : `${provisioners.length} provisioners`; - - const extraTags = Object.entries(provisioner.tags).filter( + const extraTags = Object.entries(firstProvisioner.tags).filter( ([key]) => key !== "scope" && key !== "owner", ); + let warnings = 0; + let provisionersWithWarnings = 0; + const provisionersWithWarningInfo = provisioners.map((it) => { + const outOfDate = Boolean(buildInfo) && it.version !== buildInfo?.version; + const warningCount = outOfDate ? 1 : 0; + warnings += warningCount; + if (warnings > 0) { + provisionersWithWarnings++; + } + + return { ...it, warningCount, outOfDate }; + }); + + const hasWarning = warnings > 0; + const warningsCount = + warnings === 0 + ? "No warnings" + : warnings === 1 + ? "1 warning" + : `${warnings} warnings`; + const provisionersWithWarningsCount = + provisionersWithWarnings === 1 + ? "1 provisioner" + : `${provisionersWithWarnings} provisioners`; + return (
= ({ gap: 24, }} > -
- {type === "builtin" && ( -
- - - {provisionerCount} — Built-in - -
- )} - {type === "psk" && ( -
- - - {provisionerCount} —{" "} - {allProvisionersAreSameVersion ? ( - {provisionerVersion} - ) : ( - Multiple versions - )} - -
- )} - {type === "key" && ( -
+
+ +
+ {type === "builtin" && ( + <> + + + {provisionerCount} — Built-in + + + )} + + {type === "psk" && } + {type === "key" && (

Key group – {keyName}

+ )} + {type !== "builtin" && ( {provisionerCount} —{" "} - {allProvisionersAreSameVersion ? ( + {provisionerVersion ? ( {provisionerVersion} ) : ( Multiple versions )} -
- )} + )} +
= ({ }} > - + : } + > {daemonScope} @@ -153,16 +177,19 @@ export const ProvisionerGroup: FC = ({ flexWrap: "wrap", }} > - {provisioners.map((provisioner) => ( + {provisionersWithWarningInfo.map((provisioner) => (
0 && styles.warningBorder, + ]} > = ({ color: theme.palette.text.secondary, }} > - No warnings from {provisionerCount} + + {warningsCount} from{" "} + {hasWarning ? provisionersWithWarningsCount : provisionerCount} + + } /> )} - {provisioners.psk.length > 0 && ( - - )} - {[...provisioners.keys].map(([keyId, provisioners]) => ( - - ))} + {provisioners.map((group) => { + const type = getGroupType(group.key); + + // We intentionally hide user-authenticated provisioners for now + // because there are 1. some grouping issues on the backend and 2. we + // should ideally group them by the user who authenticated them, and + // not just lump them all together. + if (type === "userAuth") { + return null; + } + + return ( + + ); + })}
); }; + +// Ideally these would be generated and appear in typesGenerated.ts, but that is +// not currently the case. In the meantime, these are taken from verbatim from +// the corresponding codersdk declarations. The names remain unchanged to keep +// usage of these special values "grep-able". +// https://github.com/coder/coder/blob/7c77a3cc832fb35d9da4ca27df163c740f786137/codersdk/provisionerdaemons.go#L291-L295 +const ProvisionerKeyIDBuiltIn = "00000000-0000-0000-0000-000000000001"; +const ProvisionerKeyIDUserAuth = "00000000-0000-0000-0000-000000000002"; +const ProvisionerKeyIDPSK = "00000000-0000-0000-0000-000000000003"; + +function getGroupType(key: ProvisionerKey) { + switch (key.id) { + case ProvisionerKeyIDBuiltIn: + return "builtin"; + case ProvisionerKeyIDUserAuth: + return "userAuth"; + case ProvisionerKeyIDPSK: + return "psk"; + default: + return "key"; + } +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0bf829bb1e..9d9f3192fd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -575,10 +575,28 @@ export const MockProvisionerKey: TypesGen.ProvisionerKey = { tags: { scope: "organization" }, }; +export const MockProvisionerBuiltinKey: TypesGen.ProvisionerKey = { + ...MockProvisionerKey, + id: "00000000-0000-0000-0000-000000000001", + name: "built-in", +}; + +export const MockProvisionerUserAuthKey: TypesGen.ProvisionerKey = { + ...MockProvisionerKey, + id: "00000000-0000-0000-0000-000000000002", + name: "user-auth", +}; + +export const MockProvisionerPskKey: TypesGen.ProvisionerKey = { + ...MockProvisionerKey, + id: "00000000-0000-0000-0000-000000000003", + name: "psk", +}; + export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", id: "test-provisioner", - key_id: "00000000-0000-0000-0000-000000000001", + key_id: MockProvisionerBuiltinKey.id, organization_id: MockOrganization.id, name: "Test Provisioner", provisioners: ["echo"], @@ -591,7 +609,7 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = { export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = { ...MockProvisioner, id: "test-user-auth-provisioner", - key_id: "00000000-0000-0000-0000-000000000002", + key_id: MockProvisionerUserAuthKey.id, name: `${MockUser.name}'s provisioner`, tags: { scope: "user" }, }; @@ -599,7 +617,7 @@ export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = { export const MockPskProvisioner: TypesGen.ProvisionerDaemon = { ...MockProvisioner, id: "test-psk-provisioner", - key_id: "00000000-0000-0000-0000-000000000003", + key_id: MockProvisionerPskKey.id, name: "Test psk provisioner", };