feat(site): add warnings and status indicator to provisioner groups (#14708)

This commit is contained in:
Kayla Washburn-Love
2024-09-20 09:55:04 -06:00
committed by GitHub
parent 86f68b220e
commit 96e9a4f85c
10 changed files with 331 additions and 183 deletions

View File

@ -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())

View File

@ -692,6 +692,18 @@ class ApiMethods {
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
getProvisionerDaemonGroupsByOrganization = async (
organization: string,
): Promise<TypesGen.ProvisionerKeyDaemons[]> => {
const response = await this.axios.get<TypesGen.ProvisionerKeyDaemons[]>(
`/api/v2/organizations/${organization}/provisionerkeys/daemons`,
);
return response.data;
};
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
const response = await this.axios.get<TypesGen.Template>(
`/api/v2/templates/${templateId}`,

View File

@ -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.
*

View File

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StatusIndicator } from "./StatusIndicator";
const meta: Meta<typeof StatusIndicator> = {
title: "components/StatusIndicator",
component: StatusIndicator,
args: {},
};
export default meta;
type Story = StoryObj<typeof StatusIndicator>;
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",
},
};

View File

@ -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(),
);
});
},

View File

@ -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<ProvisionerGroupProps> = ({
@ -40,36 +41,65 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
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 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 (
<div
css={{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
fontSize: 14,
}}
css={[
{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
fontSize: 14,
},
hasWarning && styles.warningBorder,
]}
>
<header
css={{
@ -80,48 +110,39 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
gap: 24,
}}
>
<div
css={{
display: "flex",
alignItems: "center",
gap: 24,
objectFit: "fill",
}}
>
{type === "builtin" && (
<div css={{ lineHeight: "160%" }}>
<BuiltinProvisionerTitle />
<span css={{ color: theme.palette.text.secondary }}>
{provisionerCount} &mdash; Built-in
</span>
</div>
)}
{type === "psk" && (
<div css={{ lineHeight: "160%" }}>
<PskProvisionerTitle />
<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%" }}>
<div css={{ display: "flex", alignItems: "center", gap: 16 }}>
<StatusIndicator color={hasWarning ? "warning" : "success"} />
<div
css={{
display: "flex",
flexDirection: "column",
lineHeight: 1.5,
}}
>
{type === "builtin" && (
<>
<BuiltinProvisionerTitle />
<span css={{ color: theme.palette.text.secondary }}>
{provisionerCount} &mdash; Built-in
</span>
</>
)}
{type === "psk" && <PskProvisionerTitle />}
{type === "key" && (
<h4 css={styles.groupTitle}>Key group &ndash; {keyName}</h4>
)}
{type !== "builtin" && (
<span css={{ color: theme.palette.text.secondary }}>
{provisionerCount} &mdash;{" "}
{allProvisionersAreSameVersion ? (
{provisionerVersion ? (
<code>{provisionerVersion}</code>
) : (
<span>Multiple versions</span>
)}
</span>
</div>
)}
)}
</div>
</div>
<div
css={{
@ -133,7 +154,10 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
}}
>
<Tooltip title="Scope">
<Pill size="lg" icon={iconScope}>
<Pill
size="lg"
icon={daemonScope === "organization" ? <Business /> : <Person />}
>
<span css={{ textTransform: "capitalize" }}>{daemonScope}</span>
</Pill>
</Tooltip>
@ -153,16 +177,19 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
flexWrap: "wrap",
}}
>
{provisioners.map((provisioner) => (
{provisionersWithWarningInfo.map((provisioner) => (
<div
key={provisioner.id}
css={{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
fontSize: 14,
padding: "14px 18px",
width: 375,
}}
css={[
{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
fontSize: 14,
padding: "14px 18px",
width: 375,
},
provisioner.warningCount > 0 && styles.warningBorder,
]}
>
<Stack
direction="row"
@ -215,7 +242,10 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
color: theme.palette.text.secondary,
}}
>
<span>No warnings from {provisionerCount}</span>
<span>
{warningsCount} from{" "}
{hasWarning ? provisionersWithWarningsCount : provisionerCount}
</span>
<Button
variant="text"
css={{
@ -379,6 +409,10 @@ const PskProvisionerTitle: FC = () => {
};
const styles = {
warningBorder: (theme) => ({
borderColor: theme.roles.warning.fill.outline,
}),
groupTitle: {
fontWeight: 500,
margin: 0,
@ -389,7 +423,7 @@ const styles = {
marginBottom: 0,
color: theme.palette.text.primary,
fontSize: 14,
lineHeight: "150%",
lineHeight: 1.5,
fontWeight: 600,
}),

View File

@ -1,7 +1,7 @@
import { buildInfo } from "api/queries/buildInfo";
import {
organizationsPermissions,
provisionerDaemons,
provisionerDaemonGroups,
} from "api/queries/organizations";
import type { Organization, ProvisionerDaemon } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
@ -13,50 +13,7 @@ import type { FC } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
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;
}
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
const OrganizationProvisionersPage: FC = () => {
const { organization: organizationName } = useParams() as {
@ -73,7 +30,7 @@ const OrganizationProvisionersPage: FC = () => {
const permissionsQuery = useQuery(
organizationsPermissions(organizations?.map((o) => o.id)),
);
const provisionersQuery = useQuery(provisionerDaemons(organizationName));
const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName));
if (!organization) {
return <EmptyState message="Organization not found" />;
@ -105,7 +62,7 @@ const OrganizationProvisionersPage: FC = () => {
return (
<OrganizationProvisionersPageView
buildInfo={buildInfoQuery.data}
provisioners={groupProvisioners(provisioners)}
provisioners={provisioners}
/>
);
};
@ -115,6 +72,4 @@ export default OrganizationProvisionersPage;
const getOrganizationByName = (
organizations: readonly Organization[],
name: string,
) => {
return organizations.find((org) => org.name === name);
};
) => organizations.find((org) => org.name === name);

View File

@ -3,6 +3,9 @@ import {
MockBuildInfo,
MockProvisioner,
MockProvisioner2,
MockProvisionerBuiltinKey,
MockProvisionerKey,
MockProvisionerPskKey,
MockProvisionerWithTags,
MockUserProvisioner,
} from "testHelpers/entities";
@ -21,42 +24,54 @@ type Story = StoryObj<typeof OrganizationProvisionersPageView>;
export const Provisioners: Story = {
args: {
provisioners: {
builtin: [MockProvisioner, MockProvisioner2],
psk: [MockProvisioner, MockUserProvisioner, MockProvisionerWithTags],
userAuth: [],
keys: new Map([
[
"ベン",
[
MockProvisioner,
{
...MockProvisioner2,
version: "2.0.0",
api_version: "1.0",
warnings: [{ code: "EUNKNOWN", message: "私は時代遅れです" }],
},
],
provisioners: [
{
key: MockProvisionerBuiltinKey,
daemons: [MockProvisioner, MockProvisioner2],
},
{
key: MockProvisionerPskKey,
daemons: [
MockProvisioner,
MockUserProvisioner,
MockProvisionerWithTags,
],
["ジャイデン", [MockProvisioner, MockProvisioner2]],
[
"ケイラ",
[
{
...MockProvisioner,
tags: {
...MockProvisioner.tags,
: "ユタ",
: "yes",
: "no",
},
warnings: [
{ code: "EUNKNOWN", message: "私は日本語が話せません" },
],
},
],
},
{
key: { ...MockProvisionerKey, id: "ジャイデン", name: "ジャイデン" },
daemons: [MockProvisioner, MockProvisioner2],
},
{
key: { ...MockProvisionerKey, id: "ベン", name: "ベン" },
daemons: [
MockProvisioner,
{
...MockProvisioner2,
version: "2.0.0",
api_version: "1.0",
},
],
]),
},
},
{
key: { ...MockProvisionerKey, id: "ケイラ", name: "ケイラ" },
daemons: [
{
...MockProvisioner,
tags: {
...MockProvisioner.tags,
: "ユタ",
: "yes",
: "no",
},
},
],
},
],
},
};
export const Empty: Story = {
args: {
provisioners: [],
},
};

View File

@ -1,27 +1,30 @@
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Button from "@mui/material/Button";
import type { BuildInfoResponse, ProvisionerDaemon } from "api/typesGenerated";
import type {
BuildInfoResponse,
ProvisionerKey,
ProvisionerKeyDaemons,
} from "api/typesGenerated";
import { EmptyState } from "components/EmptyState/EmptyState";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
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 {
/** Info about the version of coderd */
buildInfo?: BuildInfoResponse;
provisioners: ProvisionersByGroup;
/** Groups of provisioners, along with their key information */
provisioners: ProvisionerKeyDaemons[];
}
export const OrganizationProvisionersPageView: FC<
OrganizationProvisionersPageViewProps
> = ({ buildInfo, provisioners }) => {
const isEmpty = provisioners.every((group) => group.daemons.length === 0);
return (
<div>
<PageHeader
@ -40,30 +43,65 @@ export const OrganizationProvisionersPageView: FC<
<PageHeaderTitle>Provisioners</PageHeaderTitle>
</PageHeader>
<Stack spacing={4.5}>
{provisioners.builtin.length > 0 && (
<ProvisionerGroup
buildInfo={buildInfo}
type="builtin"
provisioners={provisioners.builtin}
{isEmpty && (
<EmptyState
message="No provisioners"
description="A provisioner is required before you can create templates and workspaces. You can connect your first provisioner by following our documentation."
cta={
<Button
endIcon={<OpenInNewIcon />}
target="_blank"
href={docs("/admin/provisioners")}
>
Show me how to create a provisioner
</Button>
}
/>
)}
{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}
/>
))}
{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 (
<ProvisionerGroup
key={group.key.id}
buildInfo={buildInfo}
keyName={group.key.name}
type={type}
provisioners={group.daemons}
/>
);
})}
</Stack>
</div>
);
};
// 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";
}
}

View File

@ -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",
};