mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor: redesign workspace status on workspaces table (#17425)
Closes https://github.com/coder/coder/issues/17310 **Before:** <img width="1624" alt="Screenshot 2025-04-16 at 11 49 52" src="https://github.com/user-attachments/assets/4fb6c8e5-329f-476f-99bb-192c0f9562a2" /> **After:** <img width="1624" alt="Screenshot 2025-04-16 at 11 49 19" src="https://github.com/user-attachments/assets/c7025fee-fefd-4064-9101-d7a1b364dd80" /> **Notice!** - I've create a new size variation for the badge, `xs`. Since we reduced the line-height for the `text-xs` to be 16px instead of 18px, having a smaller badge, reducing the vertical size and horizontal paddings, just worked better. - I have to update Figma to reflect these changes. I tried, but I was not able to get it working and updated correctly. I'm going to take a pause during this week to learn that. - Updated the destructive, and warning badges to use borders as defined in the designs [here](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=489-3472&t=gfnYeLOIFUqHx6qv-0).
This commit is contained in:
@@ -17,9 +17,12 @@ export const badgeVariants = cva(
|
||||
default:
|
||||
"border-transparent bg-surface-secondary text-content-secondary shadow",
|
||||
warning:
|
||||
"border-transparent bg-surface-orange text-content-warning shadow",
|
||||
"border border-solid border-border-warning bg-surface-orange text-content-warning shadow",
|
||||
destructive:
|
||||
"border border-solid border-border-destructive bg-surface-red text-content-highlight-red shadow",
|
||||
},
|
||||
size: {
|
||||
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
|
||||
sm: "text-2xs font-regular h-5.5 [&_svg]:size-icon-xs",
|
||||
md: "text-xs font-medium [&_svg]:size-icon-sm",
|
||||
},
|
||||
|
||||
+5
-1
@@ -28,11 +28,13 @@
|
||||
--surface-grey: 240 5% 96%;
|
||||
--surface-orange: 34 100% 92%;
|
||||
--surface-sky: 201 94% 86%;
|
||||
--surface-red: 0 93% 94%;
|
||||
--border-default: 240 6% 90%;
|
||||
--border-success: 142 76% 36%;
|
||||
--border-warning: 30.66, 97.16%, 72.35%;
|
||||
--border-destructive: 0 84% 60%;
|
||||
--border-hover: 240, 5%, 34%;
|
||||
--border-warning: 27 96% 61%;
|
||||
--border-hover: 240 5% 34%;
|
||||
--overlay-default: 240 5% 84% / 80%;
|
||||
--radius: 0.5rem;
|
||||
--highlight-purple: 262 83% 58%;
|
||||
@@ -66,10 +68,12 @@
|
||||
--surface-grey: 240 6% 10%;
|
||||
--surface-orange: 13 81% 15%;
|
||||
--surface-sky: 204 80% 16%;
|
||||
--surface-red: 0 75% 15%;
|
||||
--border-default: 240 4% 16%;
|
||||
--border-success: 142 76% 36%;
|
||||
--border-warning: 30.66, 97.16%, 72.35%;
|
||||
--border-destructive: 0 91% 71%;
|
||||
--border-warning: 31 97% 72%;
|
||||
--border-hover: 240, 5%, 34%;
|
||||
--overlay-default: 240 10% 4% / 80%;
|
||||
--highlight-purple: 252 95% 85%;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import AutoDeleteIcon from "@mui/icons-material/AutoDelete";
|
||||
import RecyclingIcon from "@mui/icons-material/Recycling";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { FC } from "react";
|
||||
|
||||
@@ -35,9 +33,9 @@ export const WorkspaceDormantBadge: FC<WorkspaceDormantBadgeProps> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Pill role="status" icon={<AutoDeleteIcon />} type="error">
|
||||
<Badge role="status" variant="destructive" size="xs">
|
||||
Deletion Pending
|
||||
</Pill>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
@@ -50,9 +48,9 @@ export const WorkspaceDormantBadge: FC<WorkspaceDormantBadgeProps> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Pill role="status" icon={<RecyclingIcon />} type="warning">
|
||||
<Badge role="status" variant="warning" size="xs">
|
||||
Dormant
|
||||
</Pill>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
StatusIndicator,
|
||||
StatusIndicatorDot,
|
||||
type StatusIndicatorProps,
|
||||
} from "components/StatusIndicator/StatusIndicator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -25,19 +30,26 @@ import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
|
||||
import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge";
|
||||
import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
|
||||
import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge";
|
||||
import { LastUsed } from "pages/WorkspacesPage/LastUsed";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { cn } from "utils/cn";
|
||||
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
|
||||
import {
|
||||
type DisplayWorkspaceStatusType,
|
||||
getDisplayWorkspaceStatus,
|
||||
getDisplayWorkspaceTemplateName,
|
||||
lastUsedMessage,
|
||||
} from "utils/workspace";
|
||||
import { WorkspacesEmpty } from "./WorkspacesEmpty";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export interface WorkspacesTableProps {
|
||||
workspaces?: readonly Workspace[];
|
||||
checkedWorkspaces: readonly Workspace[];
|
||||
@@ -125,8 +137,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
</TableHead>
|
||||
{hasAppStatus && <TableHead className="w-2/6">Activity</TableHead>}
|
||||
<TableHead className="w-2/6">Template</TableHead>
|
||||
<TableHead className="w-1/6">Last used</TableHead>
|
||||
<TableHead className="w-1/6">Status</TableHead>
|
||||
<TableHead className="w-2/6">Status</TableHead>
|
||||
<TableHead className="w-0" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -248,26 +259,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<LastUsed lastUsedAt={workspace.last_used_at} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<WorkspaceStatusBadge workspace={workspace} />
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && (
|
||||
<InfoTooltip
|
||||
type="warning"
|
||||
title="Workspace is unhealthy"
|
||||
message="Your workspace is running but some agents are unhealthy."
|
||||
/>
|
||||
)}
|
||||
{workspace.dormant_at && (
|
||||
<WorkspaceDormantBadge workspace={workspace} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<WorkspaceStatusCell workspace={workspace} />
|
||||
|
||||
<TableCell>
|
||||
<div className="flex pl-4">
|
||||
@@ -345,14 +337,11 @@ const TableLoader: FC<TableLoaderProps> = ({ canCheckWorkspaces }) => {
|
||||
<TableCell className="w-2/6">
|
||||
<AvatarDataSkeleton />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/6">
|
||||
<Skeleton variant="text" width="75%" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/6">
|
||||
<Skeleton variant="text" width="75%" />
|
||||
<TableCell className="w-2/6">
|
||||
<Skeleton variant="text" width="50%" />
|
||||
</TableCell>
|
||||
<TableCell className="w-0">
|
||||
<Skeleton variant="text" width="75%" />
|
||||
<Skeleton variant="text" width="25%" />
|
||||
</TableCell>
|
||||
</TableRowSkeleton>
|
||||
</TableLoaderSkeleton>
|
||||
@@ -362,3 +351,51 @@ const TableLoader: FC<TableLoaderProps> = ({ canCheckWorkspaces }) => {
|
||||
const cantBeChecked = (workspace: Workspace) => {
|
||||
return ["deleting", "pending"].includes(workspace.latest_build.status);
|
||||
};
|
||||
|
||||
type WorkspaceStatusCellProps = {
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const variantByStatusType: Record<
|
||||
DisplayWorkspaceStatusType,
|
||||
StatusIndicatorProps["variant"]
|
||||
> = {
|
||||
active: "pending",
|
||||
inactive: "inactive",
|
||||
success: "success",
|
||||
error: "failed",
|
||||
danger: "warning",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
const WorkspaceStatusCell: FC<WorkspaceStatusCellProps> = ({ workspace }) => {
|
||||
const { text, type } = getDisplayWorkspaceStatus(
|
||||
workspace.latest_build.status,
|
||||
workspace.latest_build.job,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<StatusIndicator variant={variantByStatusType[type]}>
|
||||
<StatusIndicatorDot />
|
||||
{text}
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && (
|
||||
<InfoTooltip
|
||||
type="warning"
|
||||
title="Workspace is unhealthy"
|
||||
message="Your workspace is running but some agents are unhealthy."
|
||||
/>
|
||||
)}
|
||||
{workspace.dormant_at && (
|
||||
<WorkspaceDormantBadge workspace={workspace} />
|
||||
)}
|
||||
</StatusIndicator>
|
||||
<span className="text-xs font-medium text-content-secondary ml-6">
|
||||
{lastUsedMessage(workspace.last_used_at)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,14 +168,29 @@ export const getDisplayWorkspaceTemplateName = (
|
||||
: workspace.template_name;
|
||||
};
|
||||
|
||||
export type DisplayWorkspaceStatusType =
|
||||
| "success"
|
||||
| "active"
|
||||
| "inactive"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
type DisplayWorkspaceStatus = {
|
||||
text: string;
|
||||
type: DisplayWorkspaceStatusType;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export const getDisplayWorkspaceStatus = (
|
||||
workspaceStatus: TypesGen.WorkspaceStatus,
|
||||
provisionerJob?: TypesGen.ProvisionerJob,
|
||||
) => {
|
||||
): DisplayWorkspaceStatus => {
|
||||
switch (workspaceStatus) {
|
||||
case undefined:
|
||||
return {
|
||||
text: "Loading",
|
||||
type: "active",
|
||||
icon: <PillSpinner />,
|
||||
} as const;
|
||||
case "running":
|
||||
@@ -307,3 +322,23 @@ const FALLBACK_ICON = "/icon/widgets.svg";
|
||||
export const getResourceIconPath = (resourceType: string): string => {
|
||||
return BUILT_IN_ICON_PATHS[resourceType] ?? FALLBACK_ICON;
|
||||
};
|
||||
|
||||
export const lastUsedMessage = (lastUsedAt: string | Date): string => {
|
||||
const t = dayjs(lastUsedAt);
|
||||
const now = dayjs();
|
||||
let message = t.fromNow();
|
||||
|
||||
if (t.isAfter(now.subtract(1, "hour"))) {
|
||||
message = "Now";
|
||||
} else if (t.isAfter(now.subtract(3, "day"))) {
|
||||
message = t.fromNow();
|
||||
} else if (t.isAfter(now.subtract(1, "month"))) {
|
||||
message = t.fromNow();
|
||||
} else if (t.isAfter(now.subtract(100, "year"))) {
|
||||
message = t.fromNow();
|
||||
} else {
|
||||
message = "Never";
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ module.exports = {
|
||||
grey: "hsl(var(--surface-grey))",
|
||||
orange: "hsl(var(--surface-orange))",
|
||||
sky: "hsl(var(--surface-sky))",
|
||||
red: "hsl(var(--surface-red))",
|
||||
},
|
||||
border: {
|
||||
DEFAULT: "hsl(var(--border-default))",
|
||||
|
||||
Reference in New Issue
Block a user