mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor: improve app status and statuses (#18121)
#### 1. Gray out status icons when the workspace is not running. **Before:** <img width="1624" alt="Screenshot 2025-05-29 at 21 33 45" src="https://github.com/user-attachments/assets/7916e707-e5ae-4226-8234-39c42f0ec8c4" /> **After:** <img width="1624" alt="Screenshot 2025-05-29 at 21 35 07" src="https://github.com/user-attachments/assets/52fd8553-414d-4c49-a44e-7a530f0d522d" /> #### 2. Truncate long messages **Before** <img width="1213" alt="Screenshot 2025-05-29 at 21 28 50" src="https://github.com/user-attachments/assets/b76b7a4d-7ef0-41a0-822c-c32c98f997fc" /> **After** <img width="1206" alt="Screenshot 2025-05-29 at 21 25 42" src="https://github.com/user-attachments/assets/fb3f3916-a4a4-4697-b7d1-0b4873b6e528" /> #### 3. Disable "show more" button if there is one single status
This commit is contained in:
@@ -25,22 +25,22 @@ export const AppStatusIcon: FC<AppStatusIconProps> = ({
|
||||
switch (status.state) {
|
||||
case "complete":
|
||||
return (
|
||||
<CircleCheckIcon className={cn([className, "text-content-success"])} />
|
||||
<CircleCheckIcon className={cn(["text-content-success", className])} />
|
||||
);
|
||||
case "failure":
|
||||
return (
|
||||
<CircleAlertIcon className={cn([className, "text-content-warning"])} />
|
||||
<CircleAlertIcon className={cn(["text-content-warning", className])} />
|
||||
);
|
||||
case "working":
|
||||
return latest ? (
|
||||
<Spinner size="sm" className="shrink-0" loading />
|
||||
) : (
|
||||
<HourglassIcon className={cn([className, "text-highlight-sky"])} />
|
||||
<HourglassIcon className={cn(["text-highlight-sky", className])} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TriangleAlertIcon
|
||||
className={cn([className, "text-content-secondary"])}
|
||||
className={cn(["text-content-secondary", className])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,3 +48,10 @@ export const LongMessage: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
status: MockWorkspaceAppStatus,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import type {
|
||||
WorkspaceAppStatus as APIWorkspaceAppStatus,
|
||||
WorkspaceAppStatusState,
|
||||
} from "api/typesGenerated";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import type { WorkspaceAppStatus as APIWorkspaceAppStatus } from "api/typesGenerated";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const iconByState: Record<WorkspaceAppStatusState, ReactNode> = {
|
||||
complete: (
|
||||
<CircleCheckIcon className="size-4 shrink-0 text-content-success" />
|
||||
),
|
||||
failure: <CircleAlertIcon className="size-4 shrink-0 text-content-warning" />,
|
||||
working: <Spinner size="sm" className="shrink-0" loading />,
|
||||
type WorkspaceAppStatusProps = {
|
||||
status: APIWorkspaceAppStatus | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const WorkspaceAppStatus = ({
|
||||
status,
|
||||
}: {
|
||||
status: APIWorkspaceAppStatus | null;
|
||||
}) => {
|
||||
disabled,
|
||||
}: WorkspaceAppStatusProps) => {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="text-content-disabled text-sm">
|
||||
@@ -39,7 +31,13 @@ export const WorkspaceAppStatus = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
{iconByState[status.state]}
|
||||
<AppStatusIcon
|
||||
status={status}
|
||||
latest
|
||||
className={cn({
|
||||
"text-content-disabled": disabled,
|
||||
})}
|
||||
/>
|
||||
<span className="whitespace-nowrap max-w-72 overflow-hidden text-ellipsis text-sm text-content-primary font-medium">
|
||||
{status.message}
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { WorkspaceAppStatus } from "api/typesGenerated";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
MockWorkspaceAppStatus,
|
||||
MockWorkspaceAppStatuses,
|
||||
createTimestamp,
|
||||
} from "testHelpers/entities";
|
||||
import { withProxyProvider } from "testHelpers/storybook";
|
||||
import { AppStatuses } from "./AppStatuses";
|
||||
@@ -13,6 +16,8 @@ const meta: Meta<typeof AppStatuses> = {
|
||||
component: AppStatuses,
|
||||
args: {
|
||||
referenceDate: new Date("2024-03-26T15:15:00Z"),
|
||||
agent: mockAgent(MockWorkspaceAppStatuses),
|
||||
workspace: MockWorkspace,
|
||||
},
|
||||
decorators: [withProxyProvider()],
|
||||
};
|
||||
@@ -21,163 +26,70 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppStatuses>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
workspace: MockWorkspace,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
statuses: [
|
||||
{
|
||||
// This is the latest status chronologically (15:04:38)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-7",
|
||||
icon: "/emojis/1f4dd.png", // 📝
|
||||
message: "Creating PR with gh CLI",
|
||||
created_at: createTimestamp(4, 38), // 15:04:38
|
||||
uri: "https://github.com/coder/coder/pull/5678",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:03:56)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-6",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Pushing branch to remote",
|
||||
created_at: createTimestamp(3, 56), // 15:03:56
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:02:29)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-5",
|
||||
icon: "/emojis/1f527.png", // 🔧
|
||||
message: "Configuring git identity",
|
||||
created_at: createTimestamp(2, 29), // 15:02:29
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:02:04)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-4",
|
||||
icon: "/emojis/1f4be.png", // 💾
|
||||
message: "Committing changes",
|
||||
created_at: createTimestamp(2, 4), // 15:02:04
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:44)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-3",
|
||||
icon: "/emojis/2795.png", // +
|
||||
message: "Adding files to staging",
|
||||
created_at: createTimestamp(1, 44), // 15:01:44
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:32)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-2",
|
||||
icon: "/emojis/1f33f.png", // 🌿
|
||||
message: "Creating a new branch for PR",
|
||||
created_at: createTimestamp(1, 32), // 15:01:32
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:00) - Oldest
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-1",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Starting to create a PR",
|
||||
created_at: createTimestamp(1, 0), // 15:01:00
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime(),
|
||||
), // Ensure sorted correctly for component input if needed
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Pass the reference date to the component for Storybook rendering
|
||||
},
|
||||
};
|
||||
export const Default: Story = {};
|
||||
|
||||
// Add a story with a "Working" status as the latest
|
||||
export const WorkingState: Story = {
|
||||
args: {
|
||||
workspace: MockWorkspace,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
statuses: [
|
||||
{
|
||||
// This is now the latest (15:05:15) and is "working"
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-8",
|
||||
icon: "", // Let the component handle the spinner icon
|
||||
message: "Processing final checks...",
|
||||
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
|
||||
uri: "",
|
||||
state: "working" as const,
|
||||
},
|
||||
{
|
||||
// Previous latest (15:04:38)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-7",
|
||||
icon: "/emojis/1f4dd.png", // 📝
|
||||
message: "Creating PR with gh CLI",
|
||||
created_at: createTimestamp(4, 38), // 15:04:38
|
||||
uri: "https://github.com/coder/coder/pull/5678",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:03:56)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-6",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Pushing branch to remote",
|
||||
created_at: createTimestamp(3, 56), // 15:03:56
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
// ... include other older statuses if desired ...
|
||||
{
|
||||
// (15:01:00) - Oldest
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-1",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Starting to create a PR",
|
||||
created_at: createTimestamp(1, 0), // 15:01:00
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime(),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
agent: mockAgent([
|
||||
{
|
||||
// This is now the latest (15:05:15) and is "working"
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-8",
|
||||
icon: "", // Let the component handle the spinner icon
|
||||
message: "Processing final checks...",
|
||||
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
|
||||
uri: "",
|
||||
state: "working" as const,
|
||||
},
|
||||
...MockWorkspaceAppStatuses,
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
function createTimestamp(minuteOffset: number, secondOffset: number) {
|
||||
const baseDate = new Date("2024-03-26T15:00:00Z");
|
||||
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
|
||||
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
|
||||
return baseDate.toISOString();
|
||||
export const LongStatusText: Story = {
|
||||
args: {
|
||||
agent: mockAgent([
|
||||
{
|
||||
// This is now the latest (15:05:15) and is "working"
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-8",
|
||||
icon: "", // Let the component handle the spinner icon
|
||||
message:
|
||||
"Processing final checks with a very long message that exceeds the usual length to test how the component handles overflow and truncation in the UI. This should be long enough to ensure it wraps correctly and doesn't break the layout.",
|
||||
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
...MockWorkspaceAppStatuses,
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleStatus: Story = {
|
||||
args: {
|
||||
agent: mockAgent([
|
||||
{
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-1",
|
||||
icon: "",
|
||||
message: "Initial setup complete.",
|
||||
created_at: createTimestamp(5, 10), // 15:05:10 (after referenceDate)
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
function mockAgent(statuses: WorkspaceAppStatus[]) {
|
||||
return {
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
statuses,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,15 +103,17 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
<div className="flex flex-col border border-solid border-border rounded-lg">
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between px-4 py-3
|
||||
flex items-center justify-between px-4 py-3 gap-6
|
||||
border-0 [&:not(:last-child)]:border-b border-solid border-border
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-content-primary flex items-center gap-2">
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="text-sm font-medium text-content-primary flex items-center gap-2 ">
|
||||
<AppStatusIcon status={latestStatus} latest />
|
||||
{latestStatus.message}
|
||||
</span>
|
||||
<span className="block flex-1 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{latestStatus.message}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
|
||||
{timeFrom(new Date(latestStatus.created_at), comparisonDate)}
|
||||
</span>
|
||||
@@ -154,6 +156,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
disabled={otherStatuses.length === 0}
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
|
||||
@@ -297,7 +297,10 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
|
||||
{hasActivity && (
|
||||
<TableCell>
|
||||
<WorkspaceAppStatus status={workspace.latest_app_status} />
|
||||
<WorkspaceAppStatus
|
||||
status={workspace.latest_app_status}
|
||||
disabled={workspace.latest_build.status !== "running"}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4392,3 +4392,83 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = {
|
||||
},
|
||||
devcontainer_dirty: false,
|
||||
};
|
||||
|
||||
export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [
|
||||
{
|
||||
// This is the latest status chronologically (15:04:38)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-7",
|
||||
icon: "/emojis/1f4dd.png", // 📝
|
||||
message: "Creating PR with gh CLI",
|
||||
created_at: createTimestamp(4, 38), // 15:04:38
|
||||
uri: "https://github.com/coder/coder/pull/5678",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:03:56)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-6",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Pushing branch to remote",
|
||||
created_at: createTimestamp(3, 56), // 15:03:56
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:02:29)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-5",
|
||||
icon: "/emojis/1f527.png", // 🔧
|
||||
message: "Configuring git identity",
|
||||
created_at: createTimestamp(2, 29), // 15:02:29
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:02:04)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-4",
|
||||
icon: "/emojis/1f4be.png", // 💾
|
||||
message: "Committing changes",
|
||||
created_at: createTimestamp(2, 4), // 15:02:04
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:44)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-3",
|
||||
icon: "/emojis/2795.png", // +
|
||||
message: "Adding files to staging",
|
||||
created_at: createTimestamp(1, 44), // 15:01:44
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:32)
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-2",
|
||||
icon: "/emojis/1f33f.png", // 🌿
|
||||
message: "Creating a new branch for PR",
|
||||
created_at: createTimestamp(1, 32), // 15:01:32
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
{
|
||||
// (15:01:00) - Oldest
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "status-1",
|
||||
icon: "/emojis/1f680.png", // 🚀
|
||||
message: "Starting to create a PR",
|
||||
created_at: createTimestamp(1, 0), // 15:01:00
|
||||
uri: "",
|
||||
state: "complete" as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function createTimestamp(minuteOffset: number, secondOffset: number) {
|
||||
const baseDate = new Date("2024-03-26T15:00:00Z");
|
||||
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
|
||||
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
|
||||
return baseDate.toISOString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user