mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): move resources into the sidebar (#11456)
This commit is contained in:
@@ -27,8 +27,26 @@ export const SidebarLink = (props: LinkProps) => {
|
||||
return <Link css={styles.sidebarItem} {...props} />;
|
||||
};
|
||||
|
||||
export const SidebarItem = (props: HTMLAttributes<HTMLButtonElement>) => {
|
||||
return <button css={styles.sidebarItem} {...props} />;
|
||||
export const SidebarItem = (
|
||||
props: HTMLAttributes<HTMLButtonElement> & { isActive?: boolean },
|
||||
) => {
|
||||
const { isActive, ...buttonProps } = props;
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
css={[
|
||||
styles.sidebarItem,
|
||||
{ opacity: "0.75", "&:hover": { opacity: 1 } },
|
||||
isActive && {
|
||||
background: theme.palette.action.selected,
|
||||
opacity: 1,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
]}
|
||||
{...buttonProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarCaption = (props: HTMLAttributes<HTMLSpanElement>) => {
|
||||
@@ -88,6 +106,7 @@ const styles = {
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
import { type FC } from "react";
|
||||
import type { WorkspaceResource } from "api/typesGenerated";
|
||||
import { Avatar, AvatarIcon } from "components/Avatar/Avatar";
|
||||
|
||||
const FALLBACK_ICON = "/icon/widgets.svg";
|
||||
|
||||
// These resources (i.e. docker_image, kubernetes_deployment) map to Terraform
|
||||
// resource types. These are the most used ones and are based on user usage.
|
||||
// We may want to update from time-to-time.
|
||||
const BUILT_IN_ICON_PATHS: Record<string, string> = {
|
||||
docker_volume: "/icon/database.svg",
|
||||
docker_container: "/icon/memory.svg",
|
||||
docker_image: "/icon/container.svg",
|
||||
kubernetes_persistent_volume_claim: "/icon/database.svg",
|
||||
kubernetes_pod: "/icon/memory.svg",
|
||||
google_compute_disk: "/icon/database.svg",
|
||||
google_compute_instance: "/icon/memory.svg",
|
||||
aws_instance: "/icon/memory.svg",
|
||||
kubernetes_deployment: "/icon/memory.svg",
|
||||
};
|
||||
|
||||
export const getIconPathResource = (resourceType: string): string => {
|
||||
return BUILT_IN_ICON_PATHS[resourceType] ?? FALLBACK_ICON;
|
||||
};
|
||||
import { getResourceIconPath } from "utils/workspace";
|
||||
|
||||
export type ResourceAvatarProps = { resource: WorkspaceResource };
|
||||
|
||||
export const ResourceAvatar: FC<ResourceAvatarProps> = ({ resource }) => {
|
||||
const avatarSrc = resource.icon || getIconPathResource(resource.type);
|
||||
const avatarSrc = resource.icon || getResourceIconPath(resource.type);
|
||||
|
||||
return (
|
||||
<Avatar background>
|
||||
|
||||
@@ -15,17 +15,7 @@ const styles = {
|
||||
resourceCard: (theme) => ({
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
"&:not(:first-of-type)": {
|
||||
borderTop: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
},
|
||||
|
||||
"&:not(:last-child)": {
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
background: theme.palette.background.default,
|
||||
}),
|
||||
|
||||
resourceCardProfile: {
|
||||
|
||||
@@ -41,7 +41,6 @@ export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
|
||||
css={{
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{createDayString(build.created_at)}
|
||||
@@ -74,6 +73,6 @@ const styles = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
lineHeight: "1.4",
|
||||
lineHeight: "1.5",
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Interpolation, Theme } from "@emotion/react";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { WorkspaceResource } from "api/typesGenerated";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCaption,
|
||||
SidebarItem,
|
||||
} from "components/FullPageLayout/Sidebar";
|
||||
import { getResourceIconPath } from "utils/workspace";
|
||||
|
||||
type ResourcesSidebarProps = {
|
||||
failed: boolean;
|
||||
resources: WorkspaceResource[];
|
||||
onChange: (resourceId: string) => void;
|
||||
selected: string;
|
||||
};
|
||||
|
||||
export const ResourcesSidebar = (props: ResourcesSidebarProps) => {
|
||||
const theme = useTheme();
|
||||
const { failed, onChange, selected, resources } = props;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarCaption>Resources</SidebarCaption>
|
||||
{failed && (
|
||||
<p
|
||||
css={{
|
||||
margin: 0,
|
||||
padding: "0 16px",
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
Your workspace build failed, so the necessary resources couldn't
|
||||
be created.
|
||||
</p>
|
||||
)}
|
||||
{resources.length === 0 &&
|
||||
!failed &&
|
||||
Array.from({ length: 8 }, (_, i) => (
|
||||
<SidebarItem key={i}>
|
||||
<ResourceSidebarItemSkeleton />
|
||||
</SidebarItem>
|
||||
))}
|
||||
{resources.map((r) => (
|
||||
<SidebarItem
|
||||
onClick={() => onChange(r.id)}
|
||||
isActive={r.id === selected}
|
||||
key={r.id}
|
||||
css={styles.root}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
css={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
src={getResourceIconPath(r.type)}
|
||||
alt=""
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
css={{ display: "flex", flexDirection: "column", fontWeight: 500 }}
|
||||
>
|
||||
<span>{r.name}</span>
|
||||
<span css={{ fontSize: 12, color: theme.palette.text.secondary }}>
|
||||
{r.type}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourceSidebarItemSkeleton = () => {
|
||||
return (
|
||||
<div css={[styles.root, { pointerEvents: "none" }]}>
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
<div>
|
||||
<Skeleton variant="text" width={94} height={16} />
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width={60}
|
||||
height={14}
|
||||
css={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
lineHeight: "1.5",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -68,12 +68,6 @@ export const Running: Story = {
|
||||
workspace: Mocks.MockWorkspace,
|
||||
handleStart: action("start"),
|
||||
handleStop: action("stop"),
|
||||
resources: [
|
||||
Mocks.MockWorkspaceResourceMultipleAgents,
|
||||
Mocks.MockWorkspaceVolumeResource,
|
||||
Mocks.MockWorkspaceImageResource,
|
||||
Mocks.MockWorkspaceContainerResource,
|
||||
],
|
||||
canUpdateWorkspace: true,
|
||||
workspaceErrors: {},
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
|
||||
@@ -2,16 +2,15 @@ import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { Resources } from "components/Resources/Resources";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { DormantWorkspaceBanner } from "components/WorkspaceDeletion";
|
||||
import { AgentRow } from "components/Resources/AgentRow";
|
||||
import { useLocalStorage } from "hooks";
|
||||
import { useLocalStorage, useTab } from "hooks";
|
||||
import {
|
||||
ActiveTransition,
|
||||
WorkspaceBuildProgress,
|
||||
@@ -24,6 +23,9 @@ import { bannerHeight } from "components/Dashboard/DeploymentBanner/DeploymentBa
|
||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
|
||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||
import { ResourcesSidebar } from "./ResourcesSidebar";
|
||||
import { ResourceCard } from "components/Resources/ResourceCard";
|
||||
|
||||
export type WorkspaceError =
|
||||
| "getBuildsError"
|
||||
@@ -45,7 +47,6 @@ export interface WorkspaceProps {
|
||||
isUpdating: boolean;
|
||||
isRestarting: boolean;
|
||||
workspace: TypesGen.Workspace;
|
||||
resources?: TypesGen.WorkspaceResource[];
|
||||
canUpdateWorkspace: boolean;
|
||||
updateMessage?: string;
|
||||
canChangeVersions: boolean;
|
||||
@@ -78,8 +79,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
workspace,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
resources,
|
||||
|
||||
canUpdateWorkspace,
|
||||
updateMessage,
|
||||
canChangeVersions,
|
||||
@@ -99,8 +98,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
const { saveLocal, getLocal } = useLocalStorage();
|
||||
const theme = useTheme();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false);
|
||||
|
||||
// 2023-11-15 - MES - This effect will be called every single render because
|
||||
@@ -148,6 +145,29 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
const transitionStats =
|
||||
template !== undefined ? ActiveTransition(template, workspace) : undefined;
|
||||
|
||||
const sidebarOption = useTab("sidebar", "");
|
||||
const setSidebarOption = (newOption: string) => {
|
||||
const { set, value } = sidebarOption;
|
||||
if (value === newOption) {
|
||||
set("");
|
||||
} else {
|
||||
set(newOption);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedResourceId = useTab("resources", "");
|
||||
const resources = [...workspace.latest_build.resources].sort(
|
||||
(a, b) => countAgents(b) - countAgents(a),
|
||||
);
|
||||
const selectedResource = workspace.latest_build.resources.find(
|
||||
(r) => r.id === selectedResourceId.value,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (resources.length > 0 && selectedResourceId.value === "") {
|
||||
selectedResourceId.set(resources[0].id);
|
||||
}
|
||||
}, [resources, selectedResourceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
@@ -187,25 +207,37 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SidebarIconButton
|
||||
isActive={searchParams.get("sidebar") === "history"}
|
||||
isActive={sidebarOption.value === "resources"}
|
||||
onClick={() => {
|
||||
const sidebarOption = searchParams.get("sidebar");
|
||||
if (sidebarOption === "history") {
|
||||
searchParams.delete("sidebar");
|
||||
} else {
|
||||
searchParams.set("sidebar", "history");
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
setSidebarOption("resources");
|
||||
}}
|
||||
>
|
||||
<HubOutlined />
|
||||
</SidebarIconButton>
|
||||
<SidebarIconButton
|
||||
isActive={sidebarOption.value === "history"}
|
||||
onClick={() => {
|
||||
setSidebarOption("history");
|
||||
}}
|
||||
>
|
||||
<HistoryOutlined />
|
||||
</SidebarIconButton>
|
||||
</div>
|
||||
|
||||
{searchParams.get("sidebar") === "history" && (
|
||||
{sidebarOption.value === "resources" && (
|
||||
<ResourcesSidebar
|
||||
failed={workspace.latest_build.status === "failed"}
|
||||
resources={resources}
|
||||
selected={selectedResourceId.value}
|
||||
onChange={selectedResourceId.set}
|
||||
/>
|
||||
)}
|
||||
{sidebarOption.value === "history" && (
|
||||
<HistorySidebar workspace={workspace} />
|
||||
)}
|
||||
|
||||
@@ -342,9 +374,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
|
||||
{buildLogs}
|
||||
|
||||
{resources && resources.length > 0 && (
|
||||
<Resources
|
||||
resources={resources}
|
||||
{selectedResource && (
|
||||
<ResourceCard
|
||||
resource={selectedResource}
|
||||
agentRow={(agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
@@ -369,6 +401,10 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const countAgents = (resource: TypesGen.WorkspaceResource) => {
|
||||
return resource.agents ? resource.agents.length : 0;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
content: {
|
||||
padding: 24,
|
||||
@@ -377,6 +413,7 @@ const styles = {
|
||||
},
|
||||
|
||||
dotBackground: (theme) => ({
|
||||
minHeight: "100%",
|
||||
padding: 24,
|
||||
"--d": "1px",
|
||||
background: `
|
||||
|
||||
@@ -224,7 +224,6 @@ export const WorkspaceReadyPage = ({
|
||||
displayError(message);
|
||||
}
|
||||
}}
|
||||
resources={workspace.latest_build.resources}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
updateMessage={latestVersion?.message}
|
||||
canChangeVersions={canChangeVersions}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { getIconPathResource } from "components/Resources/ResourceAvatar";
|
||||
import { getResourceIconPath } from "utils/workspace";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
|
||||
interface UseBatchActionsProps {
|
||||
@@ -126,7 +126,7 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
...new Set(
|
||||
checkedWorkspaces.flatMap((workspace) =>
|
||||
workspace.latest_build.resources.map(
|
||||
(resource) => resource.icon || getIconPathResource(resource.type),
|
||||
(resource) => resource.icon || getResourceIconPath(resource.type),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -257,7 +257,7 @@ const Resources: FC<StageProps> = ({ workspaces }) => {
|
||||
if (!resources[resource.type]) {
|
||||
resources[resource.type] = {
|
||||
count: 0,
|
||||
icon: resource.icon || getIconPathResource(resource.type),
|
||||
icon: resource.icon || getResourceIconPath(resource.type),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -285,3 +285,23 @@ export const workspaceUpdatePolicy = (
|
||||
}
|
||||
return workspace.automatic_updates;
|
||||
};
|
||||
|
||||
// These resources (i.e. docker_image, kubernetes_deployment) map to Terraform
|
||||
// resource types. These are the most used ones and are based on user usage.
|
||||
// We may want to update from time-to-time.
|
||||
const BUILT_IN_ICON_PATHS: Record<string, `/icon/${string}`> = {
|
||||
docker_volume: "/icon/database.svg",
|
||||
docker_container: "/icon/memory.svg",
|
||||
docker_image: "/icon/container.svg",
|
||||
kubernetes_persistent_volume_claim: "/icon/database.svg",
|
||||
kubernetes_pod: "/icon/memory.svg",
|
||||
google_compute_disk: "/icon/database.svg",
|
||||
google_compute_instance: "/icon/memory.svg",
|
||||
aws_instance: "/icon/memory.svg",
|
||||
kubernetes_deployment: "/icon/memory.svg",
|
||||
};
|
||||
const FALLBACK_ICON = "/icon/widgets.svg";
|
||||
|
||||
export const getResourceIconPath = (resourceType: string): string => {
|
||||
return BUILT_IN_ICON_PATHS[resourceType] ?? FALLBACK_ICON;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user