feat: group provisioners by authentication method (#14580)

This commit is contained in:
Kayla Washburn-Love
2024-09-17 14:47:14 -06:00
committed by GitHub
parent d96adad56f
commit dda6bdc174
6 changed files with 382 additions and 23 deletions
@@ -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} &mdash; 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} &mdash;{" "}
{allProvisionersAreSameVersion ? (
<code>{provisionerVersion}</code>
) : (
<span>Multiple versions</span>
)}
</span>
</div>
)}
{type === "key" && (
<div css={{ lineHeight: "160%" }}>
<h4 css={{ fontWeight: 500, margin: 0 }}>
Key group &ndash; {keyName}
</h4>
<span css={{ color: theme.palette.text.secondary }}>
{provisionerCount} &mdash;{" "}
{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} &mdash;{" "}
{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;
@@ -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>
+44
View File
@@ -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",