mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add provisioners view to organization settings (#14501)
This commit is contained in:
committed by
GitHub
parent
c3f0db3671
commit
84922e239f
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ThemeRole } from "theme/roles";
|
||||
export type PillProps = HTMLAttributes<HTMLDivElement> & {
|
||||
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<PillProps> = forwardRef<HTMLDivElement, PillProps>(
|
||||
(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
css={[styles.pill, icon && styles.pillWithIcon, typeStyles]}
|
||||
css={[
|
||||
styles.pill,
|
||||
icon && size === "md" && styles.pillWithIcon,
|
||||
size === "lg" && styles.pillLg,
|
||||
icon && size === "lg" && styles.pillLgWithIcon,
|
||||
typeStyles,
|
||||
]}
|
||||
{...divProps}
|
||||
>
|
||||
{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
|
||||
|
||||
@@ -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<ProvisionerProps> = ({
|
||||
provisioner,
|
||||
warnings,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const daemonScope = provisioner.tags.scope || "organization";
|
||||
const iconScope = daemonScope === "organization" ? <Business /> : <Person />;
|
||||
|
||||
const extraTags = Object.entries(provisioner.tags).filter(
|
||||
([key]) => key !== "scope" && key !== "owner",
|
||||
);
|
||||
const isWarning = warnings && warnings.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={provisioner.name}
|
||||
css={[
|
||||
{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
fontSize: 14,
|
||||
},
|
||||
isWarning && { borderColor: theme.palette.warning.light },
|
||||
]}
|
||||
>
|
||||
<header
|
||||
css={{
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContenxt: "space-between",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
objectFit: "fill",
|
||||
}}
|
||||
>
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>{provisioner.name}</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
<code>{provisioner.version}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Scope">
|
||||
<Pill size="lg" icon={iconScope}>
|
||||
<span
|
||||
css={{
|
||||
":first-letter": { textTransform: "uppercase" },
|
||||
}}
|
||||
>
|
||||
{daemonScope}
|
||||
</span>
|
||||
</Pill>
|
||||
</Tooltip>
|
||||
{extraTags.map(([key, value]) => (
|
||||
<ProvisionerTag key={key} tagName={key} tagValue={value} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
css={{
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 24px",
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{warnings && warnings.length > 0 ? (
|
||||
<div css={{ display: "flex", flexDirection: "column" }}>
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning.code}>{warning.message}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>No warnings</span>
|
||||
)}
|
||||
{provisioner.last_seen_at && (
|
||||
<span css={{ color: theme.roles.info.text }} data-chromatic="ignore">
|
||||
Last seen {createDayString(provisioner.last_seen_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<ProvisionerTagProps> = ({
|
||||
tagName,
|
||||
tagValue,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { valid, value: boolValue } = parseBool(tagValue);
|
||||
const kv = (
|
||||
<>
|
||||
<span css={{ fontWeight: 600 }}>{tagName}</span> <span>{tagValue}</span>
|
||||
</>
|
||||
);
|
||||
const content = onDelete ? (
|
||||
<>
|
||||
{kv}
|
||||
<IconButton
|
||||
aria-label={`delete-${tagName}`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onDelete(tagName);
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="inherit" css={{ width: 14, height: 14 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
kv
|
||||
);
|
||||
if (valid) {
|
||||
return <BooleanPill value={boolValue}>{content}</BooleanPill>;
|
||||
}
|
||||
return (
|
||||
<Pill size="lg" icon={<Sell />}>
|
||||
{content}
|
||||
</Pill>
|
||||
);
|
||||
};
|
||||
|
||||
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
export const BooleanPill: FC<BooleanPillProps> = ({
|
||||
value,
|
||||
children,
|
||||
...divProps
|
||||
}) => {
|
||||
return (
|
||||
<Pill
|
||||
type={value ? "active" : "danger"}
|
||||
size="lg"
|
||||
icon={
|
||||
value ? (
|
||||
<CheckCircleOutlined css={styles.truePill} />
|
||||
) : (
|
||||
<DoNotDisturbOnOutlined css={styles.falsePill} />
|
||||
)
|
||||
}
|
||||
{...divProps}
|
||||
>
|
||||
{children}
|
||||
</Pill>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
truePill: (theme) => ({
|
||||
color: theme.roles.active.outline,
|
||||
}),
|
||||
falsePill: (theme) => ({
|
||||
color: theme.roles.danger.outline,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -195,7 +195,7 @@ export const BooleanPill: FC<BooleanPillProps> = ({
|
||||
...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 (
|
||||
<Pill
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Business from "@mui/icons-material/Business";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import Sell from "@mui/icons-material/Sell";
|
||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { HealthcheckReport } from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Provisioner } from "modules/provisioners/Provisioner";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { createDayString } from "utils/createDayString";
|
||||
import { pageTitle } from "utils/page";
|
||||
import {
|
||||
BooleanPill,
|
||||
Header,
|
||||
HeaderTitle,
|
||||
HealthMessageDocsLink,
|
||||
HealthyDot,
|
||||
Main,
|
||||
Pill,
|
||||
} from "./Content";
|
||||
import { DismissWarningButton } from "./DismissWarningButton";
|
||||
|
||||
export const ProvisionerDaemonsPage: FC = () => {
|
||||
const healthStatus = useOutletContext<HealthcheckReport>();
|
||||
const { provisioner_daemons: daemons } = healthStatus;
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -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" ? <Business /> : <Person />;
|
||||
|
||||
const extraTags = Object.entries(daemon.tags).filter(
|
||||
([key]) => key !== "scope" && key !== "owner",
|
||||
);
|
||||
const isWarning = warnings.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={daemon.name}
|
||||
css={{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${
|
||||
isWarning
|
||||
? theme.palette.warning.light
|
||||
: 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",
|
||||
}}
|
||||
>
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>{daemon.name}</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
<code>{daemon.version}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="API Version">
|
||||
<Pill icon={<SwapHoriz />}>
|
||||
<code>{daemon.api_version}</code>
|
||||
</Pill>
|
||||
</Tooltip>
|
||||
<Tooltip title="Scope">
|
||||
<Pill icon={iconScope}>
|
||||
<span
|
||||
css={{
|
||||
":first-letter": { textTransform: "uppercase" },
|
||||
}}
|
||||
>
|
||||
{daemonScope}
|
||||
</span>
|
||||
</Pill>
|
||||
</Tooltip>
|
||||
{extraTags.map(([key, value]) => (
|
||||
<ProvisionerTag key={key} tagName={key} tagValue={value} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
css={{
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 24px",
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{warnings.length > 0 ? (
|
||||
<div css={{ display: "flex", flexDirection: "column" }}>
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning.code}>{warning.message}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>No warnings</span>
|
||||
)}
|
||||
{daemon.last_seen_at && (
|
||||
<span
|
||||
css={{ color: theme.palette.text.secondary }}
|
||||
data-chromatic="ignore"
|
||||
>
|
||||
Last seen {createDayString(daemon.last_seen_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{daemons.items.map(({ provisioner_daemon, warnings }) => (
|
||||
<Provisioner
|
||||
key={provisioner_daemon.id}
|
||||
provisioner={provisioner_daemon}
|
||||
warnings={warnings}
|
||||
/>
|
||||
))}
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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<ProvisionerTagProps> = ({
|
||||
tagName,
|
||||
tagValue,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { valid, value: boolValue } = parseBool(tagValue);
|
||||
const kv = `${tagName}: ${tagValue}`;
|
||||
const content = onDelete ? (
|
||||
<>
|
||||
{kv}
|
||||
<IconButton
|
||||
aria-label={`delete-${tagName}`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onDelete(tagName);
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="inherit" css={{ width: 14, height: 14 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
kv
|
||||
);
|
||||
if (valid) {
|
||||
return <BooleanPill value={boolValue}>{content}</BooleanPill>;
|
||||
}
|
||||
return <Pill icon={<Sell />}>{content}</Pill>;
|
||||
};
|
||||
|
||||
export default ProvisionerDaemonsPage;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
if (permissionsQuery.isLoading || provisionersQuery.isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const permissions = permissionsQuery.data;
|
||||
const provisioners = provisionersQuery.data;
|
||||
const error = permissionsQuery.error || provisionersQuery.error;
|
||||
if (error || !permissions || !provisioners) {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
// 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 <NotFoundPage />;
|
||||
}
|
||||
|
||||
return <OrganizationProvisionersPageView provisioners={provisioners} />;
|
||||
};
|
||||
|
||||
export default OrganizationProvisionersPage;
|
||||
|
||||
const getOrganizationByName = (organizations: Organization[], name: string) =>
|
||||
organizations.find((org) => org.name === name);
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockProvisioner, MockUserProvisioner } from "testHelpers/entities";
|
||||
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
|
||||
|
||||
const meta: Meta<typeof OrganizationProvisionersPageView> = {
|
||||
title: "pages/OrganizationProvisionersPage",
|
||||
component: OrganizationProvisionersPageView,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OrganizationProvisionersPageView>;
|
||||
|
||||
export const Provisioners: Story = {
|
||||
args: {
|
||||
provisioners: [
|
||||
MockProvisioner,
|
||||
MockUserProvisioner,
|
||||
{
|
||||
...MockProvisioner,
|
||||
tags: {
|
||||
...MockProvisioner.tags,
|
||||
都市: "ユタ",
|
||||
きっぷ: "yes",
|
||||
ちいさい: "no",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
// The deployment settings layout already has padding.
|
||||
css={{ paddingTop: 0 }}
|
||||
actions={
|
||||
<Button
|
||||
endIcon={<OpenInNewIcon />}
|
||||
target="_blank"
|
||||
href={docs("/admin/provisioners")}
|
||||
>
|
||||
Create a provisioner
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>Provisioners</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
<Stack spacing={4.5}>
|
||||
{provisioners.map((provisioner) => (
|
||||
<Provisioner key={provisioner.id} provisioner={provisioner} />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 <Loader />;
|
||||
}
|
||||
|
||||
const permissions = permissionsQuery.data;
|
||||
if (permissionsQuery.error || !permissions) {
|
||||
return <ErrorAlert error={permissionsQuery.error} />;
|
||||
}
|
||||
|
||||
// Redirect /organizations => /organizations/default-org, or if they cannot edit
|
||||
// the default org, then the first org they can edit, if any.
|
||||
if (!organizationName) {
|
||||
|
||||
@@ -275,6 +275,13 @@ const OrganizationSettingsNavigation: FC<
|
||||
Roles
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{organization.permissions.viewProvisioners && (
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(organization.name, "provisioners")}
|
||||
>
|
||||
Provisioners
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
<Route path="create" element={<CreateEditRolePage />} />
|
||||
<Route path=":roleName" element={<CreateEditRolePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="provisioners"
|
||||
element={<OrganizationProvisionersPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user