diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index d8386679dd7b7..bca34d896dea4 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -223,6 +223,13 @@ export const organizationsPermissions = ( }, action: "create", }, + viewProvisioners: { + object: { + resource_type: "provisioner_daemon", + organization_id: organizationId, + }, + action: "read", + }, }); // The endpoint takes a flat array, so to avoid collisions prepend each diff --git a/site/src/components/Pill/Pill.tsx b/site/src/components/Pill/Pill.tsx index 8462b741e4869..8d6b338062a7c 100644 --- a/site/src/components/Pill/Pill.tsx +++ b/site/src/components/Pill/Pill.tsx @@ -14,6 +14,7 @@ import type { ThemeRole } from "theme/roles"; export type PillProps = HTMLAttributes & { icon?: ReactNode; type?: ThemeRole; + size?: "md" | "lg"; }; const themeStyles = (type: ThemeRole) => (theme: Theme) => { @@ -30,13 +31,25 @@ const PILL_ICON_SPACING = (PILL_HEIGHT - PILL_ICON_SIZE) / 2; export const Pill: FC = forwardRef( (props, ref) => { - const { icon, type = "inactive", children, ...divProps } = props; + const { + icon, + type = "inactive", + children, + size = "md", + ...divProps + } = props; const typeStyles = useMemo(() => themeStyles(type), [type]); return (
{icon} @@ -80,6 +93,15 @@ const styles = { paddingLeft: PILL_ICON_SPACING, }, + pillLg: { + gap: PILL_ICON_SPACING * 2, + padding: "14px 16px", + }, + + pillLgWithIcon: { + paddingLeft: PILL_ICON_SPACING * 2, + }, + spinner: (theme) => ({ color: theme.experimental.l1.text, // It is necessary to align it with the MUI Icons internal padding diff --git a/site/src/modules/provisioners/Provisioner.tsx b/site/src/modules/provisioners/Provisioner.tsx new file mode 100644 index 0000000000000..454e85025c164 --- /dev/null +++ b/site/src/modules/provisioners/Provisioner.tsx @@ -0,0 +1,117 @@ +import { useTheme } from "@emotion/react"; +import Business from "@mui/icons-material/Business"; +import Person from "@mui/icons-material/Person"; +import Tooltip from "@mui/material/Tooltip"; +import type { HealthMessage, ProvisionerDaemon } from "api/typesGenerated"; +import { Pill } from "components/Pill/Pill"; +import type { FC } from "react"; +import { createDayString } from "utils/createDayString"; +import { ProvisionerTag } from "./ProvisionerTag"; + +interface ProvisionerProps { + readonly provisioner: ProvisionerDaemon; + readonly warnings?: readonly HealthMessage[]; +} + +export const Provisioner: FC = ({ + provisioner, + warnings, +}) => { + const theme = useTheme(); + const daemonScope = provisioner.tags.scope || "organization"; + const iconScope = daemonScope === "organization" ? : ; + + const extraTags = Object.entries(provisioner.tags).filter( + ([key]) => key !== "scope" && key !== "owner", + ); + const isWarning = warnings && warnings.length > 0; + return ( +
+
+
+
+

{provisioner.name}

+ + {provisioner.version} + +
+
+
+ + + + {daemonScope} + + + + {extraTags.map(([key, value]) => ( + + ))} +
+
+ +
+ {warnings && warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( + {warning.message} + ))} +
+ ) : ( + No warnings + )} + {provisioner.last_seen_at && ( + + Last seen {createDayString(provisioner.last_seen_at)} + + )} +
+
+ ); +}; diff --git a/site/src/modules/provisioners/ProvisionerTag.tsx b/site/src/modules/provisioners/ProvisionerTag.tsx new file mode 100644 index 0000000000000..e174e4222bbfb --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerTag.tsx @@ -0,0 +1,105 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; +import CloseIcon from "@mui/icons-material/Close"; +import DoNotDisturbOnOutlined from "@mui/icons-material/DoNotDisturbOnOutlined"; +import Sell from "@mui/icons-material/Sell"; +import IconButton from "@mui/material/IconButton"; +import { Pill } from "components/Pill/Pill"; +import type { ComponentProps, FC } from "react"; + +const parseBool = (s: string): { valid: boolean; value: boolean } => { + switch (s.toLowerCase()) { + case "true": + case "yes": + case "1": + return { valid: true, value: true }; + case "false": + case "no": + case "0": + case "": + return { valid: true, value: false }; + default: + return { valid: false, value: false }; + } +}; + +interface ProvisionerTagProps { + tagName: string; + tagValue: string; + /** Only used in the TemplateVersionEditor */ + onDelete?: (tagName: string) => void; +} + +export const ProvisionerTag: FC = ({ + tagName, + tagValue, + onDelete, +}) => { + const { valid, value: boolValue } = parseBool(tagValue); + const kv = ( + <> + {tagName} {tagValue} + + ); + const content = onDelete ? ( + <> + {kv} + { + onDelete(tagName); + }} + > + + + + ) : ( + kv + ); + if (valid) { + return {content}; + } + return ( + }> + {content} + + ); +}; + +type BooleanPillProps = Omit, "icon" | "value"> & { + value: boolean; +}; + +export const BooleanPill: FC = ({ + value, + children, + ...divProps +}) => { + return ( + + ) : ( + + ) + } + {...divProps} + > + {children} + + ); +}; + +const styles = { + truePill: (theme) => ({ + color: theme.roles.active.outline, + }), + falsePill: (theme) => ({ + color: theme.roles.danger.outline, + }), +} satisfies Record>; diff --git a/site/src/pages/HealthPage/Content.tsx b/site/src/pages/HealthPage/Content.tsx index 32cec0b9f5610..485a222a1124c 100644 --- a/site/src/pages/HealthPage/Content.tsx +++ b/site/src/pages/HealthPage/Content.tsx @@ -195,7 +195,7 @@ export const BooleanPill: FC = ({ ...divProps }) => { const theme = useTheme(); - const color = value ? theme.palette.success.light : theme.palette.error.light; + const color = value ? theme.roles.success.outline : theme.roles.error.outline; return ( { const healthStatus = useOutletContext(); const { provisioner_daemons: daemons } = healthStatus; - const theme = useTheme(); + return ( <> @@ -56,169 +46,16 @@ export const ProvisionerDaemonsPage: FC = () => { ); })} - {daemons.items.map(({ provisioner_daemon: daemon, warnings }) => { - const daemonScope = daemon.tags.scope || "organization"; - const iconScope = - daemonScope === "organization" ? : ; - - const extraTags = Object.entries(daemon.tags).filter( - ([key]) => key !== "scope" && key !== "owner", - ); - const isWarning = warnings.length > 0; - return ( -
-
-
-
-

{daemon.name}

- - {daemon.version} - -
-
-
- - }> - {daemon.api_version} - - - - - - {daemonScope} - - - - {extraTags.map(([key, value]) => ( - - ))} -
-
- -
- {warnings.length > 0 ? ( -
- {warnings.map((warning) => ( - {warning.message} - ))} -
- ) : ( - No warnings - )} - {daemon.last_seen_at && ( - - Last seen {createDayString(daemon.last_seen_at)} - - )} -
-
- ); - })} + {daemons.items.map(({ provisioner_daemon, warnings }) => ( + + ))} ); }; -const parseBool = (s: string): { valid: boolean; value: boolean } => { - switch (s.toLowerCase()) { - case "true": - case "yes": - case "1": - return { valid: true, value: true }; - case "false": - case "no": - case "0": - case "": - return { valid: true, value: false }; - default: - return { valid: false, value: false }; - } -}; - -interface ProvisionerTagProps { - tagName: string; - tagValue: string; - onDelete?: (tagName: string) => void; -} - -export const ProvisionerTag: FC = ({ - tagName, - tagValue, - onDelete, -}) => { - const { valid, value: boolValue } = parseBool(tagValue); - const kv = `${tagName}: ${tagValue}`; - const content = onDelete ? ( - <> - {kv} - { - onDelete(tagName); - }} - > - - - - ) : ( - kv - ); - if (valid) { - return {content}; - } - return }>{content}; -}; - export default ProvisionerDaemonsPage; diff --git a/site/src/pages/HealthPage/healthyColor.ts b/site/src/pages/HealthPage/healthyColor.ts index a7d34d708ec1b..d8681f558fc25 100644 --- a/site/src/pages/HealthPage/healthyColor.ts +++ b/site/src/pages/HealthPage/healthyColor.ts @@ -4,10 +4,10 @@ import type { HealthSeverity } from "api/typesGenerated"; export const healthyColor = (theme: Theme, severity: HealthSeverity) => { switch (severity) { case "ok": - return theme.palette.success.light; + return theme.roles.success.fill.solid; case "warning": - return theme.palette.warning.light; + return theme.roles.warning.fill.solid; case "error": - return theme.palette.error.light; + return theme.roles.error.fill.solid; } }; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx new file mode 100644 index 0000000000000..c233826ef07fc --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx @@ -0,0 +1,63 @@ +import { + organizationsPermissions, + provisionerDaemons, +} from "api/queries/organizations"; +import type { Organization } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Loader } from "components/Loader/Loader"; +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"; + +const OrganizationProvisionersPage: FC = () => { + const { organization: organizationName } = useParams() as { + organization: string; + }; + const { organizations } = useOrganizationSettings(); + + const organization = organizations + ? getOrganizationByName(organizations, organizationName) + : undefined; + const permissionsQuery = useQuery( + organizationsPermissions(organizations?.map((o) => o.id)), + ); + const provisionersQuery = useQuery(provisionerDaemons(organizationName)); + + if (!organization) { + return ; + } + + if (permissionsQuery.isLoading || provisionersQuery.isLoading) { + return ; + } + + const permissions = permissionsQuery.data; + const provisioners = provisionersQuery.data; + const error = permissionsQuery.error || provisionersQuery.error; + if (error || !permissions || !provisioners) { + return ; + } + + // The user may not be able to edit this org but they can still see it because + // they can edit members, etc. In this case they will be shown a read-only + // summary page instead of the settings form. + // Similarly, if the feature is not entitled then the user will not be able to + // edit the organization. + if (!permissions[organization.id]?.viewProvisioners) { + // This probably doesn't work with the layout................fix this pls + // Kayla, hey, yes you, you gotta fix this. + // Don't scroll past this. It's important. Fix it!!! + return ; + } + + return ; +}; + +export default OrganizationProvisionersPage; + +const getOrganizationByName = (organizations: Organization[], name: string) => + organizations.find((org) => org.name === name); diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx new file mode 100644 index 0000000000000..99a0e4494d703 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockProvisioner, MockUserProvisioner } from "testHelpers/entities"; +import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; + +const meta: Meta = { + title: "pages/OrganizationProvisionersPage", + component: OrganizationProvisionersPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Provisioners: Story = { + args: { + provisioners: [ + MockProvisioner, + MockUserProvisioner, + { + ...MockProvisioner, + tags: { + ...MockProvisioner.tags, + 都市: "ユタ", + きっぷ: "yes", + ちいさい: "no", + }, + }, + ], + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx new file mode 100644 index 0000000000000..4374c02833183 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx @@ -0,0 +1,41 @@ +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import Button from "@mui/material/Button"; +import type { ProvisionerDaemon } from "api/typesGenerated"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { Stack } from "components/Stack/Stack"; +import { Provisioner } from "modules/provisioners/Provisioner"; +import type { FC } from "react"; +import { docs } from "utils/docs"; + +interface OrganizationProvisionersPageViewProps { + provisioners: ProvisionerDaemon[]; +} + +export const OrganizationProvisionersPageView: FC< + OrganizationProvisionersPageViewProps +> = ({ provisioners }) => { + return ( +
+ } + target="_blank" + href={docs("/admin/provisioners")} + > + Create a provisioner + + } + > + Provisioners + + + {provisioners.map((provisioner) => ( + + ))} + +
+ ); +}; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx index bce41745a2d7d..5eb1a6e0f4e49 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx @@ -4,6 +4,7 @@ import { updateOrganization, } from "api/queries/organizations"; import type { Organization } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; @@ -42,11 +43,15 @@ const OrganizationSettingsPage: FC = () => { organizationsPermissions(organizations?.map((o) => o.id)), ); - const permissions = permissionsQuery.data; - if (!organizations || !permissions) { + if (permissionsQuery.isLoading) { return ; } + const permissions = permissionsQuery.data; + if (permissionsQuery.error || !permissions) { + return ; + } + // Redirect /organizations => /organizations/default-org, or if they cannot edit // the default org, then the first org they can edit, if any. if (!organizationName) { diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index d8353a3c43f37..d635279a4d94a 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -275,6 +275,13 @@ const OrganizationSettingsNavigation: FC< Roles )} + {organization.permissions.viewProvisioners && ( + + Provisioners + + )} )} diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx index 285115bf6e7a2..71e372b32f800 100644 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx @@ -31,7 +31,7 @@ describe("ProvisionerTagsPopover", () => { await userEvent.click(btn); // Check for existing tags - const el = await screen.findByText(/scope: organization/i); + const el = await screen.findByText(/scope/i); expect(el).toBeInTheDocument(); // Add key and value @@ -62,8 +62,10 @@ describe("ProvisionerTagsPopover", () => { ); // Check for new tag - const el4 = await screen.findByText(/foo: bar/i); - expect(el4).toBeInTheDocument(); + const fooTag = await screen.findByText(/foo/i); + expect(fooTag).toBeInTheDocument(); + const barValue = await screen.findByText(/bar/i); + expect(barValue).toBeInTheDocument(); }); it("can remove a tag", async () => { const onSubmit = jest.fn().mockImplementation(({ key, value }) => { @@ -87,7 +89,7 @@ describe("ProvisionerTagsPopover", () => { await userEvent.click(btn); // Check for existing tags - const el = await screen.findByText(/wowzers: whatatag/i); + const el = await screen.findByText(/wowzers/i); expect(el).toBeInTheDocument(); // Find Delete button @@ -110,7 +112,7 @@ describe("ProvisionerTagsPopover", () => { ); // Expect deleted tag to be gone - const el2 = screen.queryByText(/wowzers: whatatag/i); + const el2 = screen.queryByText(/wowzers/i); expect(el2).not.toBeInTheDocument(); }); }); diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx index 7d802fd303c20..f696d42d09660 100644 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx +++ b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx @@ -13,7 +13,7 @@ import { } from "components/Popover/Popover"; import { Stack } from "components/Stack/Stack"; import { useFormik } from "formik"; -import { ProvisionerTag } from "pages/HealthPage/ProvisionerDaemonsPage"; +import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; import { type FC, Fragment } from "react"; import { docs } from "utils/docs"; import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; diff --git a/site/src/router.tsx b/site/src/router.tsx index 3513738ab99bc..a85cdb9a31bfb 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -251,6 +251,9 @@ const CreateEditRolePage = lazy( () => import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"), ); +const OrganizationProvisionersPage = lazy( + () => import("./pages/ManagementSettingsPage/OrganizationProvisionersPage"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -399,6 +402,10 @@ export const router = createBrowserRouter( } /> } /> + } + /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8d09460422251..3948dc148927a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -542,19 +542,15 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = { name: "Test Provisioner", provisioners: ["echo"], tags: { scope: "organization" }, - version: "v2.34.5", - api_version: "1.0", + version: MockBuildInfo.version, + api_version: MockBuildInfo.provisioner_api_version, }; export const MockUserProvisioner: TypesGen.ProvisionerDaemon = { - created_at: "2022-05-17T17:39:01.382927298Z", + ...MockProvisioner, id: "test-user-provisioner", - organization_id: MockOrganization.id, name: "Test User Provisioner", - provisioners: ["echo"], tags: { scope: "user", owner: "12345678-abcd-1234-abcd-1234567890abcd" }, - version: "v2.34.5", - api_version: "1.0", }; export const MockProvisionerJob: TypesGen.ProvisionerJob = { @@ -826,7 +822,7 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { status: "connected", updated_at: "", version: MockBuildInfo.version, - api_version: "1.0", + api_version: MockBuildInfo.agent_api_version, latency: { "Coder Embedded DERP": { latency_ms: 32.55, @@ -3313,7 +3309,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-05-01T19:15:56.606593Z", updated_at: "2023-12-05T14:13:36.647535Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, { id: "9d786ce0-55b1-4ace-8acc-a4672ff8d41f", @@ -3336,7 +3332,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-05-01T20:34:11.114005Z", updated_at: "2023-12-05T14:13:45.941716Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, { id: "2e209786-73b1-4838-ba78-e01c9334450a", @@ -3359,7 +3355,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-05-01T20:41:02.76448Z", updated_at: "2023-12-05T14:13:41.968568Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, { id: "c272e80c-0cce-49d6-9782-1b5cf90398e8", @@ -3430,7 +3426,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-12-01T09:21:15.996267Z", updated_at: "2023-12-05T14:13:59.663174Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, { id: "72649dc9-03c7-46a8-bc95-96775e93ddc1", @@ -3453,7 +3449,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-12-01T09:23:44.505529Z", updated_at: "2023-12-05T14:13:55.769058Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, { id: "1f78398f-e5ae-4c38-aa89-30222181d443", @@ -3476,7 +3472,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2023-12-01T09:36:00.231252Z", updated_at: "2023-12-05T14:13:47.015031Z", deleted: false, - version: "v2.5.0-devel+5fad61102", + version: MockBuildInfo.version, }, ], }, @@ -3502,8 +3498,8 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "ok", - version: "v2.3.4-devel+abcd1234", - api_version: "1.0", + version: MockBuildInfo.version, + api_version: MockBuildInfo.provisioner_api_version, provisioners: ["echo", "terraform"], tags: { owner: "", @@ -3523,8 +3519,8 @@ export const MockHealth: TypesGen.HealthcheckReport = { created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "user-scoped", - version: "v2.34-devel+abcd1234", - api_version: "1.0", + version: MockBuildInfo.version, + api_version: MockBuildInfo.provisioner_api_version, provisioners: ["echo", "terraform"], tags: { owner: "12345678-1234-1234-1234-12345678abcd", @@ -3569,7 +3565,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { }, ], }, - coder_version: "v2.5.0-devel+5fad61102", + coder_version: MockBuildInfo.version, }; export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse =