diff --git a/site/src/modules/provisioners/Provisioner.tsx b/site/src/modules/provisioners/Provisioner.tsx index 454e85025c164..73afc2272232e 100644 --- a/site/src/modules/provisioners/Provisioner.tsx +++ b/site/src/modules/provisioners/Provisioner.tsx @@ -67,6 +67,7 @@ export const Provisioner: FC = ({ display: "flex", flexWrap: "wrap", gap: 12, + justifyContent: "right", }} > diff --git a/site/src/modules/provisioners/ProvisionerGroup.tsx b/site/src/modules/provisioners/ProvisionerGroup.tsx new file mode 100644 index 0000000000000..eb8bf154bd808 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerGroup.tsx @@ -0,0 +1,213 @@ +import { useTheme } from "@emotion/react"; +import Business from "@mui/icons-material/Business"; +import Person from "@mui/icons-material/Person"; +import Button from "@mui/material/Button"; +import Tooltip from "@mui/material/Tooltip"; +import type { BuildInfoResponse, ProvisionerDaemon } from "api/typesGenerated"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { Pill } from "components/Pill/Pill"; +import { type FC, useState } from "react"; +import { createDayString } from "utils/createDayString"; +import { ProvisionerTag } from "./ProvisionerTag"; + +type ProvisionerGroupType = "builtin" | "psk" | "key"; + +interface ProvisionerGroupProps { + readonly buildInfo?: BuildInfoResponse; + readonly keyName?: string; + readonly type: ProvisionerGroupType; + readonly provisioners: ProvisionerDaemon[]; +} + +export const ProvisionerGroup: FC = ({ + buildInfo, + keyName, + type, + provisioners, +}) => { + const [provisioner] = provisioners; + const theme = useTheme(); + + const [showDetails, setShowDetails] = useState(false); + + const daemonScope = provisioner.tags.scope || "organization"; + const iconScope = daemonScope === "organization" ? : ; + + const provisionerVersion = provisioner.version; + const allProvisionersAreSameVersion = provisioners.every( + (provisioner) => provisioner.version === provisionerVersion, + ); + const upToDate = + allProvisionersAreSameVersion && buildInfo?.version === provisioner.version; + const provisionerCount = + provisioners.length === 1 + ? "1 provisioner" + : `${provisioners.length} provisioners`; + + const extraTags = Object.entries(provisioner.tags).filter( + ([key]) => key !== "scope" && key !== "owner", + ); + + return ( +
+
+
+ {type === "builtin" && ( +
+

+ Built-in provisioners +

+ + {provisionerCount} — Built-in + +
+ )} + {type === "psk" && ( +
+

PSK provisioners

+ + {provisionerCount} —{" "} + {allProvisionersAreSameVersion ? ( + {provisionerVersion} + ) : ( + Multiple versions + )} + +
+ )} + {type === "key" && ( +
+

+ Key group – {keyName} +

+ + {provisionerCount} —{" "} + {allProvisionersAreSameVersion ? ( + {provisionerVersion} + ) : ( + Multiple versions + )} + +
+ )} +
+
+ + + + {daemonScope} + + + + {type === "key" && + extraTags.map(([key, value]) => ( + + ))} +
+
+ + {showDetails && ( +
+ {provisioners.map((provisioner) => ( +
+
+

{provisioner.name}

+ + {type === "builtin" ? ( + Built-in + ) : ( + <> + {upToDate ? "Up to date" : provisioner.version} —{" "} + {provisioner.last_seen_at && ( + + Last seen {createDayString(provisioner.last_seen_at)} + + )} + + )} + +
+
+ ))} +
+ )} + +
+ No warnings from {provisionerCount} + +
+
+ ); +}; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx index 3a048db2cb059..4d86bc053c59e 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx @@ -1,17 +1,62 @@ +import { buildInfo } from "api/queries/buildInfo"; import { organizationsPermissions, provisionerDaemons, } from "api/queries/organizations"; -import type { Organization } from "api/typesGenerated"; +import type { Organization, ProvisionerDaemon } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Loader } from "components/Loader/Loader"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import NotFoundPage from "pages/404Page/404Page"; import type { FC } from "react"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; import { useOrganizationSettings } from "./ManagementSettingsLayout"; -import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; +import { + OrganizationProvisionersPageView, + type ProvisionersByGroup, +} from "./OrganizationProvisionersPageView"; + +const ProvisionerKeyIDBuiltIn = "00000000-0000-0000-0000-000000000001"; +const ProvisionerKeyIDUserAuth = "00000000-0000-0000-0000-000000000002"; +const ProvisionerKeyIDPSK = "00000000-0000-0000-0000-000000000003"; + +function groupProvisioners( + provisioners: readonly ProvisionerDaemon[], +): ProvisionersByGroup { + const groups: ProvisionersByGroup = { + builtin: [], + psk: [], + userAuth: [], + keys: new Map(), + }; + // NOTE: I'll fix this at the end of the PR chain + const keyName = "TODO"; + + for (const it of provisioners) { + if (it.key_id === ProvisionerKeyIDBuiltIn) { + groups.builtin.push(it); + continue; + } + if (it.key_id === ProvisionerKeyIDPSK) { + groups.psk.push(it); + continue; + } + if (it.key_id === ProvisionerKeyIDUserAuth) { + groups.userAuth.push(it); + continue; + } + + const keyGroup = groups.keys.get(keyName) ?? []; + if (!groups.keys.has(keyName)) { + groups.keys.set(keyName, keyGroup); + } + keyGroup.push(it); + } + + return groups; +} const OrganizationProvisionersPage: FC = () => { const { organization: organizationName } = useParams() as { @@ -19,6 +64,9 @@ const OrganizationProvisionersPage: FC = () => { }; const { organizations } = useOrganizationSettings(); + const { metadata } = useEmbeddedMetadata(); + const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + const organization = organizations ? getOrganizationByName(organizations, organizationName) : undefined; @@ -54,7 +102,12 @@ const OrganizationProvisionersPage: FC = () => { return ; } - return ; + return ( + + ); }; export default OrganizationProvisionersPage; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx index 99a0e4494d703..3960c36358294 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx @@ -1,10 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { MockProvisioner, MockUserProvisioner } from "testHelpers/entities"; +import { + MockBuildInfo, + MockProvisioner, + MockProvisioner2, + MockProvisionerWithTags, + MockUserProvisioner, +} from "testHelpers/entities"; import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; const meta: Meta = { title: "pages/OrganizationProvisionersPage", component: OrganizationProvisionersPageView, + args: { + buildInfo: MockBuildInfo, + }, }; export default meta; @@ -12,18 +21,29 @@ type Story = StoryObj; export const Provisioners: Story = { args: { - provisioners: [ - MockProvisioner, - MockUserProvisioner, - { - ...MockProvisioner, - tags: { - ...MockProvisioner.tags, - 都市: "ユタ", - きっぷ: "yes", - ちいさい: "no", - }, - }, - ], + provisioners: { + builtin: [MockProvisioner, MockProvisioner2], + psk: [MockProvisioner, MockUserProvisioner, MockProvisionerWithTags], + userAuth: [], + keys: new Map([ + [ + "ケイラ", + [ + { + ...MockProvisioner, + tags: { + ...MockProvisioner.tags, + 都市: "ユタ", + きっぷ: "yes", + ちいさい: "no", + }, + warnings: [ + { code: "EUNKNOWN", message: "私は日本語が話せません" }, + ], + }, + ], + ], + ]), + }, }, }; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx index 4374c02833183..7ca90ebee8e32 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx @@ -1,19 +1,27 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import Button from "@mui/material/Button"; -import type { ProvisionerDaemon } from "api/typesGenerated"; +import type { BuildInfoResponse, ProvisionerDaemon } from "api/typesGenerated"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { Stack } from "components/Stack/Stack"; -import { Provisioner } from "modules/provisioners/Provisioner"; +import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup"; import type { FC } from "react"; import { docs } from "utils/docs"; +export interface ProvisionersByGroup { + builtin: ProvisionerDaemon[]; + psk: ProvisionerDaemon[]; + userAuth: ProvisionerDaemon[]; + keys: Map; +} + interface OrganizationProvisionersPageViewProps { - provisioners: ProvisionerDaemon[]; + buildInfo?: BuildInfoResponse; + provisioners: ProvisionersByGroup; } export const OrganizationProvisionersPageView: FC< OrganizationProvisionersPageViewProps -> = ({ provisioners }) => { +> = ({ buildInfo, provisioners }) => { return (
Provisioners - {provisioners.map((provisioner) => ( - + {provisioners.builtin.length > 0 && ( + + )} + {provisioners.psk.length > 0 && ( + + )} + {[...provisioners.keys].map(([keyId, provisioners]) => ( + ))}
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a7eade4e4a286..989e837a2b3b8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -578,12 +578,44 @@ export const MockProvisionerKey: TypesGen.ProvisionerKey = { export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", id: "test-provisioner", + key_id: "00000000-0000-0000-0000-000000000001", organization_id: MockOrganization.id, name: "Test Provisioner", provisioners: ["echo"], tags: { scope: "organization" }, version: MockBuildInfo.version, api_version: MockBuildInfo.provisioner_api_version, + last_seen_at: new Date().toISOString(), +}; + +export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = { + ...MockProvisioner, + id: "test-user-auth-provisioner", + key_id: "00000000-0000-0000-0000-000000000002", + name: `${MockUser.name}'s provisioner`, + tags: { scope: "user" }, +}; + +export const MockPskProvisioner: TypesGen.ProvisionerDaemon = { + ...MockProvisioner, + id: "test-psk-provisioner", + key_id: "00000000-0000-0000-0000-000000000003", + name: "Test psk provisioner", +}; + +export const MockKeyProvisioner: TypesGen.ProvisionerDaemon = { + ...MockProvisioner, + id: "test-key-provisioner", + key_id: MockProvisionerKey.id, + organization_id: MockProvisionerKey.organization, + name: "Test key provisioner", + tags: MockProvisionerKey.tags, +}; + +export const MockProvisioner2: TypesGen.ProvisionerDaemon = { + ...MockProvisioner, + id: "test-provisioner-2", + name: "Test Provisioner 2", key_id: MockProvisionerKey.id, }; @@ -594,6 +626,18 @@ export const MockUserProvisioner: TypesGen.ProvisionerDaemon = { tags: { scope: "user", owner: "12345678-abcd-1234-abcd-1234567890abcd" }, }; +export const MockProvisionerWithTags: TypesGen.ProvisionerDaemon = { + ...MockProvisioner, + id: "test-provisioner-tags", + name: "Test Provisioner with tags", + tags: { + ...MockProvisioner.tags, + 都市: "ユタ", + きっぷ: "yes", + ちいさい: "no", + }, +}; + export const MockProvisionerJob: TypesGen.ProvisionerJob = { created_at: "", id: "test-provisioner-job",