mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: group provisioners by authentication method (#14580)
This commit is contained in:
@@ -67,6 +67,7 @@ export const Provisioner: FC<ProvisionerProps> = ({
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "right",
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Scope">
|
||||
|
||||
@@ -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<ProvisionerGroupProps> = ({
|
||||
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" ? <Business /> : <Person />;
|
||||
|
||||
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 (
|
||||
<div
|
||||
css={{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
css={{
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContenxt: "space-between",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
objectFit: "fill",
|
||||
}}
|
||||
>
|
||||
{type === "builtin" && (
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>
|
||||
Built-in provisioners
|
||||
</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
{provisionerCount} — Built-in
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{type === "psk" && (
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>PSK provisioners</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
{provisionerCount} —{" "}
|
||||
{allProvisionersAreSameVersion ? (
|
||||
<code>{provisionerVersion}</code>
|
||||
) : (
|
||||
<span>Multiple versions</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{type === "key" && (
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>
|
||||
Key group – {keyName}
|
||||
</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
{provisionerCount} —{" "}
|
||||
{allProvisionersAreSameVersion ? (
|
||||
<code>{provisionerVersion}</code>
|
||||
) : (
|
||||
<span>Multiple versions</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "right",
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Scope">
|
||||
<Pill size="lg" icon={iconScope}>
|
||||
<span
|
||||
css={{
|
||||
":first-letter": { textTransform: "uppercase" },
|
||||
}}
|
||||
>
|
||||
{daemonScope}
|
||||
</span>
|
||||
</Pill>
|
||||
</Tooltip>
|
||||
{type === "key" &&
|
||||
extraTags.map(([key, value]) => (
|
||||
<ProvisionerTag key={key} tagName={key} tagValue={value} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showDetails && (
|
||||
<div
|
||||
css={{
|
||||
padding: "0 24px 24px",
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{provisioners.map((provisioner) => (
|
||||
<div
|
||||
key={provisioner.id}
|
||||
css={{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
fontSize: 14,
|
||||
padding: "12px 18px",
|
||||
width: 310,
|
||||
}}
|
||||
>
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>{provisioner.name}</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
{type === "builtin" ? (
|
||||
<span>Built-in</span>
|
||||
) : (
|
||||
<>
|
||||
{upToDate ? "Up to date" : provisioner.version} —{" "}
|
||||
{provisioner.last_seen_at && (
|
||||
<span data-chromatic="ignore">
|
||||
Last seen {createDayString(provisioner.last_seen_at)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
css={{
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 8px 8px 24px",
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<span>No warnings from {provisionerCount}</span>
|
||||
<Button
|
||||
variant="text"
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
color: theme.roles.info.text,
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
onClick={() => setShowDetails((it) => !it)}
|
||||
>
|
||||
{showDetails ? "Hide" : "Show"} provisioner details{" "}
|
||||
<DropdownArrow close={showDetails} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 <NotFoundPage />;
|
||||
}
|
||||
|
||||
return <OrganizationProvisionersPageView provisioners={provisioners} />;
|
||||
return (
|
||||
<OrganizationProvisionersPageView
|
||||
buildInfo={buildInfoQuery.data}
|
||||
provisioners={groupProvisioners(provisioners)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationProvisionersPage;
|
||||
|
||||
+34
-14
@@ -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<typeof OrganizationProvisionersPageView> = {
|
||||
title: "pages/OrganizationProvisionersPage",
|
||||
component: OrganizationProvisionersPageView,
|
||||
args: {
|
||||
buildInfo: MockBuildInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -12,18 +21,29 @@ type Story = StoryObj<typeof OrganizationProvisionersPageView>;
|
||||
|
||||
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: "私は日本語が話せません" },
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, ProvisionerDaemon[]>;
|
||||
}
|
||||
|
||||
interface OrganizationProvisionersPageViewProps {
|
||||
provisioners: ProvisionerDaemon[];
|
||||
buildInfo?: BuildInfoResponse;
|
||||
provisioners: ProvisionersByGroup;
|
||||
}
|
||||
|
||||
export const OrganizationProvisionersPageView: FC<
|
||||
OrganizationProvisionersPageViewProps
|
||||
> = ({ provisioners }) => {
|
||||
> = ({ buildInfo, provisioners }) => {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -32,8 +40,28 @@ export const OrganizationProvisionersPageView: FC<
|
||||
<PageHeaderTitle>Provisioners</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
<Stack spacing={4.5}>
|
||||
{provisioners.map((provisioner) => (
|
||||
<Provisioner key={provisioner.id} provisioner={provisioner} />
|
||||
{provisioners.builtin.length > 0 && (
|
||||
<ProvisionerGroup
|
||||
buildInfo={buildInfo}
|
||||
type="builtin"
|
||||
provisioners={provisioners.builtin}
|
||||
/>
|
||||
)}
|
||||
{provisioners.psk.length > 0 && (
|
||||
<ProvisionerGroup
|
||||
buildInfo={buildInfo}
|
||||
type="psk"
|
||||
provisioners={provisioners.psk}
|
||||
/>
|
||||
)}
|
||||
{[...provisioners.keys].map(([keyId, provisioners]) => (
|
||||
<ProvisionerGroup
|
||||
key={keyId}
|
||||
buildInfo={buildInfo}
|
||||
keyName={keyId}
|
||||
type="key"
|
||||
provisioners={provisioners}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user