feat: add frontend for app statuses (#17178)

Check out the stories for the exacts...


![image](https://github.com/user-attachments/assets/a1e1b9b0-7b37-4e0d-b99e-64b4766519ef)


![image](https://github.com/user-attachments/assets/d3eb580d-071c-4caf-b393-a7e87da61f5e)
This commit is contained in:
Kyle Carberry
2025-04-01 12:35:58 -04:00
committed by GitHub
parent 27d2343adf
commit 583a0c652f
9 changed files with 1449 additions and 36 deletions

View File

@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import {
MockProxyLatencies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockWorkspaceAppStatus,
} from "testHelpers/entities";
import { WorkspaceAppStatus } from "./WorkspaceAppStatus";
const meta: Meta<typeof WorkspaceAppStatus> = {
title: "modules/workspaces/WorkspaceAppStatus",
component: WorkspaceAppStatus,
decorators: [
(Story) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
clearProxy: () => {
return;
},
setProxy: () => {
return;
},
refetchProxyLatencies: (): Date => {
return new Date();
},
}}
>
<Story />
</ProxyContext.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof WorkspaceAppStatus>;
export const Complete: Story = {
args: {
status: MockWorkspaceAppStatus,
},
};
export const Failure: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
state: "failure",
message: "Couldn't figure out how to start the dev server",
},
},
};
export const Working: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
state: "working",
message: "Starting dev server...",
uri: "",
},
},
};
export const LongURI: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
uri: "https://www.google.com/search?q=hello+world+plus+a+lot+of+other+words",
},
},
};
export const FileURI: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
uri: "file:///Users/jason/Desktop/test.txt",
},
},
};
export const LongMessage: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
message:
"This is a long message that will wrap around the component. It should wrap many times because this is very very very very very long.",
},
},
};
export const WithApp: Story = {
args: {
status: MockWorkspaceAppStatus,
app: {
...MockWorkspaceApp,
},
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
},
};

View File

@ -0,0 +1,300 @@
import type { Theme } from "@emotion/react";
import { useTheme } from "@emotion/react";
import AppsIcon from "@mui/icons-material/Apps";
import CheckCircle from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import InsertDriveFile from "@mui/icons-material/InsertDriveFile";
import OpenInNew from "@mui/icons-material/OpenInNew";
import Warning from "@mui/icons-material/Warning";
import CircularProgress from "@mui/material/CircularProgress";
import type {
WorkspaceAppStatus as APIWorkspaceAppStatus,
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import { createAppLinkHref } from "utils/apps";
const formatURI = (uri: string) => {
try {
const url = new URL(uri);
return url.hostname + url.pathname;
} catch {
return uri;
}
};
const getStatusColor = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
) => {
switch (state) {
case "complete":
return theme.palette.success.main;
case "failure":
return theme.palette.error.main;
case "working":
return theme.palette.primary.main;
default:
// Assuming unknown state maps to warning/secondary visually
return theme.palette.text.secondary;
}
};
const getStatusIcon = (theme: Theme, state: APIWorkspaceAppStatus["state"]) => {
const color = getStatusColor(theme, state);
switch (state) {
case "complete":
return <CheckCircle sx={{ color, fontSize: 16 }} />;
case "failure":
return <ErrorIcon sx={{ color, fontSize: 16 }} />;
case "working":
return <CircularProgress size={16} sx={{ color }} />;
default:
return <Warning sx={{ color, fontSize: 16 }} />;
}
};
export const WorkspaceAppStatus = ({
workspace,
status,
agent,
app,
}: {
workspace: Workspace;
status?: APIWorkspaceAppStatus | null;
app?: WorkspaceApp;
agent?: WorkspaceAgent;
}) => {
const theme = useTheme();
const { proxy } = useProxy();
const preferredPathBase = proxy.preferredPathAppURL;
const appsHost = proxy.preferredWildcardHostname;
const commonStyles = {
fontSize: "12px",
lineHeight: "15px",
color: theme.palette.text.disabled,
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 6px",
borderRadius: "6px",
bgcolor: "transparent",
minWidth: 0,
maxWidth: "fit-content",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
transition: "all 0.15s ease-in-out",
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.secondary,
},
};
if (!status) {
return (
<div
css={{
display: "flex",
alignItems: "center",
gap: 12,
minWidth: 0,
paddingRight: 16,
}}
>
<div
css={{
fontSize: "14px",
color: theme.palette.text.disabled,
flexShrink: 1,
minWidth: 0,
}}
>
</div>
</div>
);
}
const isFileURI = status.uri?.startsWith("file://");
let appHref: string | undefined;
if (app && agent) {
const appSlug = app.slug || app.display_name;
appHref = createAppLinkHref(
window.location.protocol,
preferredPathBase,
appsHost,
appSlug,
workspace.owner_name,
workspace,
agent,
app,
);
}
return (
<div
css={{
display: "flex",
alignItems: "flex-start",
gap: 8,
minWidth: 0,
paddingRight: 16,
}}
>
<div
css={{
display: "flex",
alignItems: "center",
flexShrink: 0,
marginTop: 2,
}}
>
{getStatusIcon(theme, status.state)}
</div>
<div
css={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
flex: 1,
}}
>
<div
css={{
fontSize: "14px",
lineHeight: "20px",
color: "text.primary",
margin: 0,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{status.message}
</div>
<div
css={{
display: "flex",
alignItems: "center",
}}
>
{app && appHref && (
<a
href={appHref}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
marginRight: 8,
position: "relative",
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
"& img": {
opacity: 1,
},
},
}}
>
{app.icon ? (
<img
src={app.icon}
alt={`${app.display_name} icon`}
width={14}
height={14}
css={{
borderRadius: "3px",
opacity: 0.8,
marginRight: 4,
}}
/>
) : (
<AppsIcon
sx={{
fontSize: 14,
opacity: 0.7,
}}
/>
)}
<span>{app.display_name}</span>
</a>
)}
{status.uri && (
<div
css={{
display: "flex",
minWidth: 0,
}}
>
{isFileURI ? (
<div
css={{
...commonStyles,
}}
>
<InsertDriveFile
sx={{
fontSize: "11px",
opacity: 0.5,
mr: 0.25,
}}
/>
<span>{formatURI(status.uri)}</span>
</div>
) : (
<a
href={status.uri}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
},
}}
>
<OpenInNew
sx={{
fontSize: 11,
opacity: 0.7,
mt: -0.125,
flexShrink: 0,
mr: 0.5,
}}
/>
<span
css={{
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatURI(status.uri)}
</span>
</a>
)}
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,207 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import {
MockProxyLatencies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockWorkspaceAppStatus,
} from "testHelpers/entities";
import { AppStatuses } from "./AppStatuses";
const meta: Meta<typeof AppStatuses> = {
title: "pages/WorkspacePage/AppStatuses",
component: AppStatuses,
// Add decorator for ProxyContext
decorators: [
(Story) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
clearProxy: () => {
return;
},
setProxy: () => {
return;
},
refetchProxyLatencies: (): Date => {
return new Date();
},
}}
>
<Story />
</ProxyContext.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof AppStatuses>;
// Helper function to create timestamps easily
const createTimestamp = (
minuteOffset: number,
secondOffset: number,
): string => {
const baseDate = new Date("2024-03-26T15:00:00Z");
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
return baseDate.toISOString();
};
// Define a fixed reference date for Storybook, slightly after the last status
const storyReferenceDate = new Date("2024-03-26T15:15:00Z"); // 15 minutes after base
export const Default: Story = {
args: {
workspace: MockWorkspace,
agents: [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
referenceDate: storyReferenceDate,
},
};
// Add a story with a "Working" status as the latest
export const WorkingState: Story = {
args: {
workspace: MockWorkspace,
agents: [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(),
),
},
],
referenceDate: storyReferenceDate, // Use the same reference date
},
};

View File

@ -0,0 +1,406 @@
import type { Theme } from "@emotion/react";
import { useTheme } from "@emotion/react";
import AppsIcon from "@mui/icons-material/Apps";
import CheckCircle from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import HelpOutline from "@mui/icons-material/HelpOutline";
import InsertDriveFile from "@mui/icons-material/InsertDriveFile";
import OpenInNew from "@mui/icons-material/OpenInNew";
import Warning from "@mui/icons-material/Warning";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type {
WorkspaceAppStatus as APIWorkspaceAppStatus,
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { DividerWithText } from "pages/DeploymentSettingsPage/LicensesSettingsPage/DividerWithText";
import type { FC } from "react";
import { createAppLinkHref } from "utils/apps";
const getStatusColor = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
) => {
switch (state) {
case "complete":
return theme.palette.success.main;
case "failure":
return theme.palette.error.main;
case "working":
return theme.palette.primary.main;
default:
// Assuming unknown state maps to warning/secondary visually
return theme.palette.text.secondary;
}
};
const getStatusIcon = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
isLatest: boolean,
) => {
// Determine color: Use state color if latest, otherwise use disabled text color (grey)
const color = isLatest
? getStatusColor(theme, state)
: theme.palette.text.disabled;
switch (state) {
case "complete":
return <CheckCircle sx={{ color, fontSize: 18 }} />;
case "failure":
return <ErrorIcon sx={{ color, fontSize: 18 }} />;
case "working":
return <CircularProgress size={18} sx={{ color }} />;
default:
return <Warning sx={{ color, fontSize: 18 }} />;
}
};
const commonStyles = {
fontSize: "12px",
lineHeight: "15px",
color: "text.disabled",
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 0.75,
py: 0.25,
borderRadius: "6px",
bgcolor: "transparent",
minWidth: 0,
maxWidth: "fit-content",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
transition: "all 0.15s ease-in-out",
"&:hover": {
textDecoration: "none",
bgcolor: "action.hover",
color: "text.secondary",
},
"& .MuiSvgIcon-root": {
// Consistent icon styling within links
fontSize: 11,
opacity: 0.7,
mt: "-1px", // Slight vertical alignment adjustment
flexShrink: 0,
},
};
const formatURI = (uri: string) => {
if (uri.startsWith("file://")) {
const path = uri.slice(7);
// Slightly shorter truncation for this context if needed
if (path.length > 35) {
const start = path.slice(0, 15);
const end = path.slice(-15);
return `${start}...${end}`;
}
return path;
}
try {
const url = new URL(uri);
const fullUrl = url.toString();
// Slightly shorter truncation
if (fullUrl.length > 40) {
const start = fullUrl.slice(0, 20);
const end = fullUrl.slice(-20);
return `${start}...${end}`;
}
return fullUrl;
} catch {
// Slightly shorter truncation
if (uri.length > 35) {
const start = uri.slice(0, 15);
const end = uri.slice(-15);
return `${start}...${end}`;
}
return uri;
}
};
// --- Component Implementation ---
export interface AppStatusesProps {
apps: WorkspaceApp[];
workspace: Workspace;
agents: ReadonlyArray<WorkspaceAgent>;
/** Optional reference date for calculating relative time. Defaults to Date.now(). Useful for Storybook. */
referenceDate?: Date;
}
// Extend the API status type to include the app icon and the app itself
interface StatusWithAppInfo extends APIWorkspaceAppStatus {
appIcon?: string; // Kept for potential future use, but we'll primarily use app.icon
app?: WorkspaceApp; // Store the full app object
}
export const AppStatuses: FC<AppStatusesProps> = ({
apps,
workspace,
agents,
referenceDate,
}) => {
const theme = useTheme();
const { proxy } = useProxy();
const preferredPathBase = proxy.preferredPathAppURL;
const appsHost = proxy.preferredWildcardHostname;
// 1. Flatten all statuses and include the parent app object
const allStatuses: StatusWithAppInfo[] = apps.flatMap((app) =>
app.statuses.map((status) => ({
...status,
app: app, // Store the parent app object
})),
);
// 2. Sort statuses chronologically (newest first)
allStatuses.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// Determine the reference point for time calculation
const comparisonDate = referenceDate ?? new Date();
if (allStatuses.length === 0) {
return null;
}
return (
<div
css={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}
>
{allStatuses.map((status, index) => {
const isLatest = index === 0;
const isFileURI = status.uri?.startsWith("file://");
const statusTime = new Date(status.created_at);
// Use formatDistance if referenceDate is provided, otherwise formatDistanceToNow
const formattedTimestamp = referenceDate
? formatDistance(statusTime, comparisonDate, { addSuffix: true })
: formatDistanceToNow(statusTime, { addSuffix: true });
// Get the associated app for this status
const currentApp = status.app;
let appHref: string | undefined;
const agent = agents.find((agent) => agent.id === status.agent_id);
if (currentApp && agent) {
const appSlug = currentApp.slug || currentApp.display_name;
appHref = createAppLinkHref(
window.location.protocol,
preferredPathBase,
appsHost,
appSlug,
workspace.owner_name,
workspace,
agent,
currentApp,
);
}
// Determine if app link should be shown
const showAppLink =
isLatest ||
(index > 0 && status.app_id !== allStatuses[index - 1].app_id);
return (
<div
key={status.id}
css={{
display: "flex",
alignItems: "flex-start", // Align icon with the first line of text
gap: 12,
backgroundColor: theme.palette.background.paper,
borderRadius: 8,
padding: 12,
opacity: isLatest ? 1 : 0.65, // Apply opacity if not the latest
transition: "opacity 0.15s ease-in-out", // Add smooth transition
"&:hover": {
opacity: 1, // Restore opacity on hover for older items
},
}}
>
{/* Icon Column */}
<div
css={{
flexShrink: 0,
marginTop: 2,
display: "flex",
alignItems: "center",
}}
>
{getStatusIcon(theme, status.state, isLatest) || (
<HelpOutline sx={{ fontSize: 18, color: "text.disabled" }} />
)}
</div>
{/* Content Column */}
<div
css={{
display: "flex",
flexDirection: "column",
gap: 4,
minWidth: 0,
flex: 1,
}}
>
{/* Message */}
<div
css={{
fontSize: 14,
lineHeight: "20px",
color: theme.palette.text.primary,
fontWeight: 500,
}}
>
{status.message}
</div>
{/* Links Row */}
<div
css={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
marginTop: 4,
minWidth: 0,
}}
>
{/* Conditional App Link */}
{currentApp && appHref && showAppLink && (
<Tooltip
title={`Open ${currentApp.display_name}`}
placement="top"
>
<Link
href={appHref}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
position: "relative",
"& .MuiSvgIcon-root": {
fontSize: 14,
opacity: 0.7,
mr: 0.5,
},
"& img": {
opacity: 0.8,
marginRight: 0.5,
},
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary, // Keep consistent hover color
"& img": {
opacity: 1,
},
"& .MuiSvgIcon-root": {
opacity: 1,
},
},
}}
>
{currentApp.icon ? (
<img
src={currentApp.icon}
alt={`${currentApp.display_name} icon`}
width={14}
height={14}
style={{ borderRadius: "3px" }}
/>
) : (
<AppsIcon />
)}
{/* Keep app name short */}
<span
css={{
lineHeight: 1,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{currentApp.display_name}
</span>
</Link>
</Tooltip>
)}
{/* Existing URI Link */}
{status.uri && (
<div css={{ display: "flex", minWidth: 0, width: "100%" }}>
{isFileURI ? (
<Tooltip title="This file is located in your workspace">
<div
css={{
...commonStyles,
"&:hover": {
bgcolor: "action.hover",
color: "text.secondary",
},
}}
>
<InsertDriveFile sx={{ mr: 0.5 }} />
{formatURI(status.uri)}
</div>
</Tooltip>
) : (
<Link
href={status.uri}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
"&:hover": {
...commonStyles["&:hover"],
color: "text.primary", // Keep hover color
},
}}
>
<OpenInNew sx={{ mr: 0.5 }} />
<div
css={{
bgcolor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flexShrink: 1, // Allow text to shrink
}}
>
{formatURI(status.uri)}
</div>
</Link>
)}
</div>
)}
</div>
{/* Timestamp */}
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
marginTop: 2,
}}
>
{formattedTimestamp}
</div>
</div>
</div>
);
})}
</div>
);
};

View File

@ -7,6 +7,17 @@ import { withDashboardProvider } from "testHelpers/storybook";
import { Workspace } from "./Workspace";
import type { WorkspacePermissions } from "./permissions";
// Helper function to create timestamps easily - Copied from AppStatuses.stories.tsx
const createTimestamp = (
minuteOffset: number,
secondOffset: number,
): string => {
const baseDate = new Date("2024-03-26T15:00:00Z");
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
return baseDate.toISOString();
};
const permissions: WorkspacePermissions = {
readWorkspace: true,
updateWorkspace: true,
@ -66,6 +77,17 @@ export const Running: Story = {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspace.latest_build,
resources: [
{
...Mocks.MockWorkspaceResource,
agents: [
{
...Mocks.MockWorkspaceAgent,
lifecycle_state: "ready",
},
],
},
],
matched_provisioners: {
count: 0,
available: 0,
@ -79,6 +101,117 @@ export const Running: Story = {
},
};
export const RunningWithAppStatuses: Story = {
args: {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspace.latest_build,
resources: [
{
...Mocks.MockWorkspaceResource,
agents: [
{
...Mocks.MockWorkspaceAgent,
lifecycle_state: "ready",
apps: [
{
...Mocks.MockWorkspaceApp,
statuses: [
{
...Mocks.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: "working" as const,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.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,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.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,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.MockWorkspaceAppStatus,
id: "status-4",
icon: "/emojis/1f4be.png", // 💾
message: "Committing changes",
created_at: createTimestamp(2, 4), // 15:02:04
uri: "",
state: "complete" as const,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.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,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.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,
agent_id: Mocks.MockWorkspaceAgent.id,
},
{
...Mocks.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,
agent_id: Mocks.MockWorkspaceAgent.id,
},
].sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
), // Ensure sorted correctly if component relies on input order
},
],
},
],
},
],
matched_provisioners: {
count: 1,
available: 1,
},
},
},
handleStart: action("start"),
handleStop: action("stop"),
buildInfo: Mocks.MockBuildInfo,
template: Mocks.MockTemplate,
},
};
export const AppIcons: Story = {
args: {
...Running.args,

View File

@ -4,14 +4,16 @@ import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
import AlertTitle from "@mui/material/AlertTitle";
import type * as TypesGen from "api/typesGenerated";
import type { WorkspaceApp } from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { AgentRow } from "modules/resources/AgentRow";
import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings";
import type { FC } from "react";
import { type FC, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { AppStatuses } from "./AppStatuses";
import { HistorySidebar } from "./HistorySidebar";
import { ResourceMetadata } from "./ResourceMetadata";
import { ResourcesSidebar } from "./ResourcesSidebar";
@ -119,6 +121,14 @@ export const Workspace: FC<WorkspaceProps> = ({
const shouldShowProvisionerAlert =
workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting;
const hasAppStatus = useMemo(() => {
return selectedResource?.agents?.some((agent) => {
return agent.apps?.some((app) => {
return app.statuses?.length > 0;
});
});
}, [selectedResource]);
return (
<div
css={{
@ -249,46 +259,144 @@ export const Workspace: FC<WorkspaceProps> = ({
<WorkspaceBuildLogsSection logs={buildLogs} />
)}
{/* Container for Agent Rows + Activity Sidebar */}
{selectedResource && (
<section
css={{ display: "flex", flexDirection: "column", gap: 24 }}
>
{selectedResource.agents?.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
template={template}
sshPrefix={sshPrefix}
showApps={permissions.updateWorkspace}
showBuiltinApps={permissions.updateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
<div css={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
{/* Left Side: Agent Rows */}
<section
css={{
display: "flex",
flexDirection: "column",
gap: 24,
flexGrow: 1,
minWidth: 0 /* Prevent overflow */,
}}
>
{selectedResource.agents?.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
template={template}
sshPrefix={sshPrefix}
showApps={permissions.updateWorkspace}
showBuiltinApps={permissions.updateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
{(!selectedResource.agents ||
selectedResource.agents?.length === 0) && (
{(!selectedResource.agents ||
selectedResource.agents?.length === 0) && (
<div
css={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
<div>
<h4 css={{ fontSize: 16, fontWeight: 500 }}>
No agents are currently assigned to this resource.
</h4>
</div>
</div>
)}
</section>
{/* Right Side: Activity Box */}
{hasAppStatus && (
<div
css={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
// Mimic AgentRow styling but with subtler border
border: `1px solid ${theme.palette.divider}`, // Use divider color
borderRadius: "8px",
boxShadow: theme.shadows[3],
width: 360,
flexShrink: 0,
backgroundColor: theme.palette.background.default, // Add background color
overflow: "hidden",
}}
>
<div>
<h4 css={{ fontSize: 16, fontWeight: 500 }}>
No agents are currently assigned to this resource.
</h4>
{/* Activity Header */}
<div
css={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: theme.palette.background.paper,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
borderBottom: `1px solid ${theme.palette.divider}`, // Add separator
}}
>
<div
css={{
fontWeight: 500,
fontSize: 14,
}}
>
Activity
</div>
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
}}
>
{
// Calculate total status count
selectedResource.agents
?.flatMap((agent) => agent.apps ?? [])
.reduce(
(count, app) => count + (app.statuses?.length ?? 0),
0,
)
}{" "}
Total
</div>
</div>
<div
css={{
maxHeight: 800,
overflowY: "auto",
// Thin scrollbar styles
"&::-webkit-scrollbar": {
width: "6px",
},
"&::-webkit-scrollbar-track": {
background: theme.palette.background.paper, // Match header background
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.divider, // Use divider color
borderRadius: "3px",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.text.secondary, // Darken on hover
},
}}
>
<AppStatuses
apps={
selectedResource.agents?.flatMap(
(agent) => agent.apps ?? [],
) as WorkspaceApp[]
}
workspace={workspace}
agents={selectedResource.agents || []}
/>
</div>
</div>
)}
</section>
</div>
)}
<WorkspaceTimings

View File

@ -10,6 +10,7 @@ import {
getDefaultFilterProps,
} from "components/Filter/storyHelpers";
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import dayjs from "dayjs";
import uniqueId from "lodash/uniqueId";
import type { ComponentProps } from "react";
@ -17,9 +18,12 @@ import {
MockBuildInfo,
MockOrganization,
MockPendingProvisionerJob,
MockProxyLatencies,
MockStoppedWorkspace,
MockTemplate,
MockUser,
MockWorkspace,
MockWorkspaceAppStatus,
mockApiError,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
@ -141,7 +145,31 @@ const meta: Meta<typeof WorkspacesPageView> = {
},
],
},
decorators: [withDashboardProvider],
decorators: [
withDashboardProvider,
(Story) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
clearProxy: () => {
return;
},
setProxy: () => {
return;
},
refetchProxyLatencies: (): Date => {
return new Date();
},
}}
>
<Story />
</ProxyContext.Provider>
),
],
};
export default meta;
@ -297,3 +325,62 @@ export const ShowOrganizations: Story = {
expect(accessibleTableCell).toBeDefined();
},
};
export const WithLatestAppStatus: Story = {
args: {
workspaces: [
{
...MockWorkspace,
latest_app_status: {
...MockWorkspaceAppStatus,
message:
"This is a long message that will wrap around the component. It should wrap many times because this is very very very very very long.",
},
},
{
...MockWorkspace,
latest_app_status: null,
},
{
...MockWorkspace,
latest_app_status: {
...MockWorkspaceAppStatus,
state: "working",
message: "Fixing the competitors page...",
},
},
{
...MockWorkspace,
latest_app_status: {
...MockWorkspaceAppStatus,
state: "failure",
message: "I couldn't figure it out...",
},
},
{
...{
...MockStoppedWorkspace,
latest_build: {
...MockStoppedWorkspace.latest_build,
resources: [],
},
},
latest_app_status: {
...MockWorkspaceAppStatus,
state: "failure",
message: "I couldn't figure it out...",
uri: "",
},
},
{
...MockWorkspace,
latest_app_status: {
...MockWorkspaceAppStatus,
state: "working",
message: "Updating the README...",
uri: "file:///home/coder/projects/coder/coder/README.md",
},
},
],
},
};

View File

@ -10,7 +10,12 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { visuallyHidden } from "@mui/utils";
import type { Template, Workspace } from "api/typesGenerated";
import type {
Template,
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
@ -22,11 +27,12 @@ import {
} from "components/TableLoader/TableLoader";
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, ReactNode } from "react";
import { type FC, type ReactNode, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
import { WorkspacesEmpty } from "./WorkspacesEmpty";
@ -55,13 +61,46 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
}) => {
const theme = useTheme();
const dashboard = useDashboard();
const workspaceIDToAppByStatus = useMemo(() => {
return (
workspaces?.reduce(
(acc, workspace) => {
if (!workspace.latest_app_status) {
return acc;
}
for (const resource of workspace.latest_build.resources) {
for (const agent of resource.agents ?? []) {
for (const app of agent.apps ?? []) {
if (app.id === workspace.latest_app_status.app_id) {
acc[workspace.id] = { app, agent };
break;
}
}
}
}
return acc;
},
{} as Record<
string,
{
app: WorkspaceApp;
agent: WorkspaceAgent;
}
>,
) || {}
);
}, [workspaces]);
const hasAppStatus = useMemo(
() => Object.keys(workspaceIDToAppByStatus).length > 0,
[workspaceIDToAppByStatus],
);
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="40%">
<TableCell width={hasAppStatus ? "30%" : "40%"}>
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
{canCheckWorkspaces && (
<Checkbox
@ -94,6 +133,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
Name
</div>
</TableCell>
{hasAppStatus && <TableCell width="30%">Activity</TableCell>}
<TableCell width="25%">Template</TableCell>
<TableCell width="20%">Last used</TableCell>
<TableCell width="15%">Status</TableCell>
@ -196,6 +236,17 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
</div>
</TableCell>
{hasAppStatus && (
<TableCell>
<WorkspaceAppStatus
workspace={workspace}
agent={workspaceIDToAppByStatus[workspace.id]?.agent}
app={workspaceIDToAppByStatus[workspace.id]?.app}
status={workspace.latest_app_status}
/>
</TableCell>
)}
<TableCell>
<div>{getDisplayWorkspaceTemplateName(workspace)}</div>

View File

@ -977,6 +977,19 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
],
};
export const MockWorkspaceAppStatus: TypesGen.WorkspaceAppStatus = {
id: "test-app-status",
created_at: "2022-05-17T17:39:01.382927298Z",
agent_id: "test-workspace-agent",
workspace_id: "test-workspace",
app_id: MockWorkspaceApp.id,
needs_user_attention: false,
icon: "/emojis/1f957.png",
uri: "https://github.com/coder/coder/pull/1234",
message: "Your competitors page is completed!",
state: "complete",
};
export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "test-workspace-agent-2",