mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat(site): add warnings and status indicator to provisioner groups (#14708)
This commit is contained in:
committed by
GitHub
parent
86f68b220e
commit
96e9a4f85c
@ -125,13 +125,13 @@ func (r *RootCmd) setOrganizationSettings(orgContext *OrganizationContext, setti
|
|||||||
|
|
||||||
settingJSON, err := json.Marshal(output)
|
settingJSON, err := json.Marshal(output)
|
||||||
if err != nil {
|
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
|
var dst bytes.Buffer
|
||||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||||
if err != nil {
|
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())
|
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||||
@ -190,13 +190,13 @@ func (r *RootCmd) printOrganizationSetting(orgContext *OrganizationContext, sett
|
|||||||
|
|
||||||
settingJSON, err := json.Marshal(output)
|
settingJSON, err := json.Marshal(output)
|
||||||
if err != nil {
|
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
|
var dst bytes.Buffer
|
||||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||||
if err != nil {
|
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())
|
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||||
|
@ -692,6 +692,18 @@ class ApiMethods {
|
|||||||
return response.data;
|
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> => {
|
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
|
||||||
const response = await this.axios.get<TypesGen.Template>(
|
const response = await this.axios.get<TypesGen.Template>(
|
||||||
`/api/v2/templates/${templateId}`,
|
`/api/v2/templates/${templateId}`,
|
||||||
|
@ -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.
|
* Fetch permissions for a single organization.
|
||||||
*
|
*
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
};
|
@ -31,7 +31,7 @@ const Example: Story = {
|
|||||||
await step("click to open", async () => {
|
await step("click to open", async () => {
|
||||||
await userEvent.click(canvas.getByRole("button"));
|
await userEvent.click(canvas.getByRole("button"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText(/v2\.99\.99/i)).toBeInTheDocument(),
|
expect(screen.getByText(/v2\.\d+\.\d+/i)).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "components/Popover/Popover";
|
} from "components/Popover/Popover";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
|
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { createDayString } from "utils/createDayString";
|
import { createDayString } from "utils/createDayString";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
@ -31,7 +32,7 @@ interface ProvisionerGroupProps {
|
|||||||
readonly buildInfo?: BuildInfoResponse;
|
readonly buildInfo?: BuildInfoResponse;
|
||||||
readonly keyName?: string;
|
readonly keyName?: string;
|
||||||
readonly type: ProvisionerGroupType;
|
readonly type: ProvisionerGroupType;
|
||||||
readonly provisioners: ProvisionerDaemon[];
|
readonly provisioners: readonly ProvisionerDaemon[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
||||||
@ -40,36 +41,65 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
|||||||
type,
|
type,
|
||||||
provisioners,
|
provisioners,
|
||||||
}) => {
|
}) => {
|
||||||
const [provisioner] = provisioners;
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
const daemonScope = provisioner.tags.scope || "organization";
|
const firstProvisioner = provisioners[0];
|
||||||
const iconScope = daemonScope === "organization" ? <Business /> : <Person />;
|
if (!firstProvisioner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const provisionerVersion = provisioner.version;
|
const daemonScope = firstProvisioner.tags.scope || "organization";
|
||||||
const allProvisionersAreSameVersion = provisioners.every(
|
const allProvisionersAreSameVersion = provisioners.every(
|
||||||
(provisioner) => provisioner.version === provisionerVersion,
|
(it) => it.version === firstProvisioner.version,
|
||||||
);
|
);
|
||||||
const upToDate =
|
const provisionerVersion = allProvisionersAreSameVersion
|
||||||
allProvisionersAreSameVersion && buildInfo?.version === provisioner.version;
|
? firstProvisioner.version
|
||||||
|
: null;
|
||||||
const provisionerCount =
|
const provisionerCount =
|
||||||
provisioners.length === 1
|
provisioners.length === 1
|
||||||
? "1 provisioner"
|
? "1 provisioner"
|
||||||
: `${provisioners.length} provisioners`;
|
: `${provisioners.length} provisioners`;
|
||||||
|
const extraTags = Object.entries(firstProvisioner.tags).filter(
|
||||||
const extraTags = Object.entries(provisioner.tags).filter(
|
|
||||||
([key]) => key !== "scope" && key !== "owner",
|
([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 (
|
return (
|
||||||
<div
|
<div
|
||||||
css={{
|
css={[
|
||||||
borderRadius: 8,
|
{
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
borderRadius: 8,
|
||||||
fontSize: 14,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
hasWarning && styles.warningBorder,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
css={{
|
css={{
|
||||||
@ -80,48 +110,39 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
|||||||
gap: 24,
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div css={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
css={{
|
<StatusIndicator color={hasWarning ? "warning" : "success"} />
|
||||||
display: "flex",
|
<div
|
||||||
alignItems: "center",
|
css={{
|
||||||
gap: 24,
|
display: "flex",
|
||||||
objectFit: "fill",
|
flexDirection: "column",
|
||||||
}}
|
lineHeight: 1.5,
|
||||||
>
|
}}
|
||||||
{type === "builtin" && (
|
>
|
||||||
<div css={{ lineHeight: "160%" }}>
|
{type === "builtin" && (
|
||||||
<BuiltinProvisionerTitle />
|
<>
|
||||||
<span css={{ color: theme.palette.text.secondary }}>
|
<BuiltinProvisionerTitle />
|
||||||
{provisionerCount} — Built-in
|
<span css={{ color: theme.palette.text.secondary }}>
|
||||||
</span>
|
{provisionerCount} — Built-in
|
||||||
</div>
|
</span>
|
||||||
)}
|
</>
|
||||||
{type === "psk" && (
|
)}
|
||||||
<div css={{ lineHeight: "160%" }}>
|
|
||||||
<PskProvisionerTitle />
|
{type === "psk" && <PskProvisionerTitle />}
|
||||||
<span css={{ color: theme.palette.text.secondary }}>
|
{type === "key" && (
|
||||||
{provisionerCount} —{" "}
|
|
||||||
{allProvisionersAreSameVersion ? (
|
|
||||||
<code>{provisionerVersion}</code>
|
|
||||||
) : (
|
|
||||||
<span>Multiple versions</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === "key" && (
|
|
||||||
<div css={{ lineHeight: "160%" }}>
|
|
||||||
<h4 css={styles.groupTitle}>Key group – {keyName}</h4>
|
<h4 css={styles.groupTitle}>Key group – {keyName}</h4>
|
||||||
|
)}
|
||||||
|
{type !== "builtin" && (
|
||||||
<span css={{ color: theme.palette.text.secondary }}>
|
<span css={{ color: theme.palette.text.secondary }}>
|
||||||
{provisionerCount} —{" "}
|
{provisionerCount} —{" "}
|
||||||
{allProvisionersAreSameVersion ? (
|
{provisionerVersion ? (
|
||||||
<code>{provisionerVersion}</code>
|
<code>{provisionerVersion}</code>
|
||||||
) : (
|
) : (
|
||||||
<span>Multiple versions</span>
|
<span>Multiple versions</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
css={{
|
css={{
|
||||||
@ -133,7 +154,10 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="Scope">
|
<Tooltip title="Scope">
|
||||||
<Pill size="lg" icon={iconScope}>
|
<Pill
|
||||||
|
size="lg"
|
||||||
|
icon={daemonScope === "organization" ? <Business /> : <Person />}
|
||||||
|
>
|
||||||
<span css={{ textTransform: "capitalize" }}>{daemonScope}</span>
|
<span css={{ textTransform: "capitalize" }}>{daemonScope}</span>
|
||||||
</Pill>
|
</Pill>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -153,16 +177,19 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{provisioners.map((provisioner) => (
|
{provisionersWithWarningInfo.map((provisioner) => (
|
||||||
<div
|
<div
|
||||||
key={provisioner.id}
|
key={provisioner.id}
|
||||||
css={{
|
css={[
|
||||||
borderRadius: 8,
|
{
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
borderRadius: 8,
|
||||||
fontSize: 14,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
padding: "14px 18px",
|
fontSize: 14,
|
||||||
width: 375,
|
padding: "14px 18px",
|
||||||
}}
|
width: 375,
|
||||||
|
},
|
||||||
|
provisioner.warningCount > 0 && styles.warningBorder,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@ -215,7 +242,10 @@ export const ProvisionerGroup: FC<ProvisionerGroupProps> = ({
|
|||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>No warnings from {provisionerCount}</span>
|
<span>
|
||||||
|
{warningsCount} from{" "}
|
||||||
|
{hasWarning ? provisionersWithWarningsCount : provisionerCount}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
css={{
|
css={{
|
||||||
@ -379,6 +409,10 @@ const PskProvisionerTitle: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
|
warningBorder: (theme) => ({
|
||||||
|
borderColor: theme.roles.warning.fill.outline,
|
||||||
|
}),
|
||||||
|
|
||||||
groupTitle: {
|
groupTitle: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@ -389,7 +423,7 @@ const styles = {
|
|||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: "150%",
|
lineHeight: 1.5,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { buildInfo } from "api/queries/buildInfo";
|
import { buildInfo } from "api/queries/buildInfo";
|
||||||
import {
|
import {
|
||||||
organizationsPermissions,
|
organizationsPermissions,
|
||||||
provisionerDaemons,
|
provisionerDaemonGroups,
|
||||||
} from "api/queries/organizations";
|
} from "api/queries/organizations";
|
||||||
import type { Organization, ProvisionerDaemon } from "api/typesGenerated";
|
import type { Organization, ProvisionerDaemon } from "api/typesGenerated";
|
||||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
@ -13,50 +13,7 @@ import type { FC } from "react";
|
|||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useOrganizationSettings } from "./ManagementSettingsLayout";
|
import { useOrganizationSettings } from "./ManagementSettingsLayout";
|
||||||
import {
|
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
|
||||||
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 OrganizationProvisionersPage: FC = () => {
|
||||||
const { organization: organizationName } = useParams() as {
|
const { organization: organizationName } = useParams() as {
|
||||||
@ -73,7 +30,7 @@ const OrganizationProvisionersPage: FC = () => {
|
|||||||
const permissionsQuery = useQuery(
|
const permissionsQuery = useQuery(
|
||||||
organizationsPermissions(organizations?.map((o) => o.id)),
|
organizationsPermissions(organizations?.map((o) => o.id)),
|
||||||
);
|
);
|
||||||
const provisionersQuery = useQuery(provisionerDaemons(organizationName));
|
const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName));
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return <EmptyState message="Organization not found" />;
|
return <EmptyState message="Organization not found" />;
|
||||||
@ -105,7 +62,7 @@ const OrganizationProvisionersPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<OrganizationProvisionersPageView
|
<OrganizationProvisionersPageView
|
||||||
buildInfo={buildInfoQuery.data}
|
buildInfo={buildInfoQuery.data}
|
||||||
provisioners={groupProvisioners(provisioners)}
|
provisioners={provisioners}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -115,6 +72,4 @@ export default OrganizationProvisionersPage;
|
|||||||
const getOrganizationByName = (
|
const getOrganizationByName = (
|
||||||
organizations: readonly Organization[],
|
organizations: readonly Organization[],
|
||||||
name: string,
|
name: string,
|
||||||
) => {
|
) => organizations.find((org) => org.name === name);
|
||||||
return organizations.find((org) => org.name === name);
|
|
||||||
};
|
|
||||||
|
@ -3,6 +3,9 @@ import {
|
|||||||
MockBuildInfo,
|
MockBuildInfo,
|
||||||
MockProvisioner,
|
MockProvisioner,
|
||||||
MockProvisioner2,
|
MockProvisioner2,
|
||||||
|
MockProvisionerBuiltinKey,
|
||||||
|
MockProvisionerKey,
|
||||||
|
MockProvisionerPskKey,
|
||||||
MockProvisionerWithTags,
|
MockProvisionerWithTags,
|
||||||
MockUserProvisioner,
|
MockUserProvisioner,
|
||||||
} from "testHelpers/entities";
|
} from "testHelpers/entities";
|
||||||
@ -21,42 +24,54 @@ type Story = StoryObj<typeof OrganizationProvisionersPageView>;
|
|||||||
|
|
||||||
export const Provisioners: Story = {
|
export const Provisioners: Story = {
|
||||||
args: {
|
args: {
|
||||||
provisioners: {
|
provisioners: [
|
||||||
builtin: [MockProvisioner, MockProvisioner2],
|
{
|
||||||
psk: [MockProvisioner, MockUserProvisioner, MockProvisionerWithTags],
|
key: MockProvisionerBuiltinKey,
|
||||||
userAuth: [],
|
daemons: [MockProvisioner, MockProvisioner2],
|
||||||
keys: new Map([
|
},
|
||||||
[
|
{
|
||||||
"ベン",
|
key: MockProvisionerPskKey,
|
||||||
[
|
daemons: [
|
||||||
MockProvisioner,
|
MockProvisioner,
|
||||||
{
|
MockUserProvisioner,
|
||||||
...MockProvisioner2,
|
MockProvisionerWithTags,
|
||||||
version: "2.0.0",
|
|
||||||
api_version: "1.0",
|
|
||||||
warnings: [{ code: "EUNKNOWN", message: "私は時代遅れです" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
["ジャイデン", [MockProvisioner, MockProvisioner2]],
|
},
|
||||||
[
|
{
|
||||||
"ケイラ",
|
key: { ...MockProvisionerKey, id: "ジャイデン", name: "ジャイデン" },
|
||||||
[
|
daemons: [MockProvisioner, MockProvisioner2],
|
||||||
{
|
},
|
||||||
...MockProvisioner,
|
{
|
||||||
tags: {
|
key: { ...MockProvisionerKey, id: "ベン", name: "ベン" },
|
||||||
...MockProvisioner.tags,
|
daemons: [
|
||||||
都市: "ユタ",
|
MockProvisioner,
|
||||||
きっぷ: "yes",
|
{
|
||||||
ちいさい: "no",
|
...MockProvisioner2,
|
||||||
},
|
version: "2.0.0",
|
||||||
warnings: [
|
api_version: "1.0",
|
||||||
{ code: "EUNKNOWN", message: "私は日本語が話せません" },
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
]),
|
},
|
||||||
},
|
{
|
||||||
|
key: { ...MockProvisionerKey, id: "ケイラ", name: "ケイラ" },
|
||||||
|
daemons: [
|
||||||
|
{
|
||||||
|
...MockProvisioner,
|
||||||
|
tags: {
|
||||||
|
...MockProvisioner.tags,
|
||||||
|
都市: "ユタ",
|
||||||
|
きっぷ: "yes",
|
||||||
|
ちいさい: "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
provisioners: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,27 +1,30 @@
|
|||||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import Button from "@mui/material/Button";
|
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 { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup";
|
import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
|
|
||||||
export interface ProvisionersByGroup {
|
|
||||||
builtin: ProvisionerDaemon[];
|
|
||||||
psk: ProvisionerDaemon[];
|
|
||||||
userAuth: ProvisionerDaemon[];
|
|
||||||
keys: Map<string, ProvisionerDaemon[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrganizationProvisionersPageViewProps {
|
interface OrganizationProvisionersPageViewProps {
|
||||||
|
/** Info about the version of coderd */
|
||||||
buildInfo?: BuildInfoResponse;
|
buildInfo?: BuildInfoResponse;
|
||||||
provisioners: ProvisionersByGroup;
|
|
||||||
|
/** Groups of provisioners, along with their key information */
|
||||||
|
provisioners: ProvisionerKeyDaemons[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OrganizationProvisionersPageView: FC<
|
export const OrganizationProvisionersPageView: FC<
|
||||||
OrganizationProvisionersPageViewProps
|
OrganizationProvisionersPageViewProps
|
||||||
> = ({ buildInfo, provisioners }) => {
|
> = ({ buildInfo, provisioners }) => {
|
||||||
|
const isEmpty = provisioners.every((group) => group.daemons.length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@ -40,30 +43,65 @@ export const OrganizationProvisionersPageView: FC<
|
|||||||
<PageHeaderTitle>Provisioners</PageHeaderTitle>
|
<PageHeaderTitle>Provisioners</PageHeaderTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Stack spacing={4.5}>
|
<Stack spacing={4.5}>
|
||||||
{provisioners.builtin.length > 0 && (
|
{isEmpty && (
|
||||||
<ProvisionerGroup
|
<EmptyState
|
||||||
buildInfo={buildInfo}
|
message="No provisioners"
|
||||||
type="builtin"
|
description="A provisioner is required before you can create templates and workspaces. You can connect your first provisioner by following our documentation."
|
||||||
provisioners={provisioners.builtin}
|
cta={
|
||||||
|
<Button
|
||||||
|
endIcon={<OpenInNewIcon />}
|
||||||
|
target="_blank"
|
||||||
|
href={docs("/admin/provisioners")}
|
||||||
|
>
|
||||||
|
Show me how to create a provisioner
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provisioners.psk.length > 0 && (
|
{provisioners.map((group) => {
|
||||||
<ProvisionerGroup
|
const type = getGroupType(group.key);
|
||||||
buildInfo={buildInfo}
|
|
||||||
type="psk"
|
// We intentionally hide user-authenticated provisioners for now
|
||||||
provisioners={provisioners.psk}
|
// 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.
|
||||||
{[...provisioners.keys].map(([keyId, provisioners]) => (
|
if (type === "userAuth") {
|
||||||
<ProvisionerGroup
|
return null;
|
||||||
key={keyId}
|
}
|
||||||
buildInfo={buildInfo}
|
|
||||||
keyName={keyId}
|
return (
|
||||||
type="key"
|
<ProvisionerGroup
|
||||||
provisioners={provisioners}
|
key={group.key.id}
|
||||||
/>
|
buildInfo={buildInfo}
|
||||||
))}
|
keyName={group.key.name}
|
||||||
|
type={type}
|
||||||
|
provisioners={group.daemons}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -575,10 +575,28 @@ export const MockProvisionerKey: TypesGen.ProvisionerKey = {
|
|||||||
tags: { scope: "organization" },
|
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 = {
|
export const MockProvisioner: TypesGen.ProvisionerDaemon = {
|
||||||
created_at: "2022-05-17T17:39:01.382927298Z",
|
created_at: "2022-05-17T17:39:01.382927298Z",
|
||||||
id: "test-provisioner",
|
id: "test-provisioner",
|
||||||
key_id: "00000000-0000-0000-0000-000000000001",
|
key_id: MockProvisionerBuiltinKey.id,
|
||||||
organization_id: MockOrganization.id,
|
organization_id: MockOrganization.id,
|
||||||
name: "Test Provisioner",
|
name: "Test Provisioner",
|
||||||
provisioners: ["echo"],
|
provisioners: ["echo"],
|
||||||
@ -591,7 +609,7 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = {
|
|||||||
export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = {
|
export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = {
|
||||||
...MockProvisioner,
|
...MockProvisioner,
|
||||||
id: "test-user-auth-provisioner",
|
id: "test-user-auth-provisioner",
|
||||||
key_id: "00000000-0000-0000-0000-000000000002",
|
key_id: MockProvisionerUserAuthKey.id,
|
||||||
name: `${MockUser.name}'s provisioner`,
|
name: `${MockUser.name}'s provisioner`,
|
||||||
tags: { scope: "user" },
|
tags: { scope: "user" },
|
||||||
};
|
};
|
||||||
@ -599,7 +617,7 @@ export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = {
|
|||||||
export const MockPskProvisioner: TypesGen.ProvisionerDaemon = {
|
export const MockPskProvisioner: TypesGen.ProvisionerDaemon = {
|
||||||
...MockProvisioner,
|
...MockProvisioner,
|
||||||
id: "test-psk-provisioner",
|
id: "test-psk-provisioner",
|
||||||
key_id: "00000000-0000-0000-0000-000000000003",
|
key_id: MockProvisionerPskKey.id,
|
||||||
name: "Test psk provisioner",
|
name: "Test psk provisioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user