diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 4e53c2cf2ba2c..f97c91e89af2a 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -15,6 +15,7 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import type { Line } from "components/Logs/LogLine"; import { Stack } from "components/Stack/Stack"; import { useProxy } from "contexts/ProxyContext"; +import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import { type FC, useCallback, @@ -225,6 +226,13 @@ export const AgentRow: FC = ({
+ {workspace.latest_app_status?.agent_id === agent.id && ( +
+

App statuses

+ +
+ )} + {agent.status === "connected" && (
{shouldDisplayApps && ( diff --git a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx index 86e6f345b5e59..a339c09e1894f 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx @@ -12,6 +12,9 @@ import { AppStatuses } from "./AppStatuses"; const meta: Meta = { title: "pages/WorkspacePage/AppStatuses", component: AppStatuses, + args: { + referenceDate: new Date("2024-03-26T15:15:00Z"), + }, // Add decorator for ProxyContext decorators: [ (Story) => ( @@ -43,106 +46,95 @@ export default meta; type Story = StoryObj; -// 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 - }, - ], + 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 - referenceDate: storyReferenceDate, }, }; @@ -150,58 +142,67 @@ export const Default: Story = { 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 + 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(), + ), + }, + ], + }, }, }; + +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(); +} diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 1b86335087ea4..5eaffe8e02bc1 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -1,19 +1,24 @@ -import type { Theme } from "@emotion/react"; -import { useTheme } from "@emotion/react"; -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 { formatDistance, formatDistanceToNow } from "date-fns"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Spinner } from "components/Spinner/Spinner"; import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { formatDistance } from "date-fns"; +import { + ChevronDownIcon, + ChevronUpIcon, CircleAlertIcon, CircleCheckIcon, - CircleHelpIcon, ExternalLinkIcon, FileIcon, HourglassIcon, @@ -21,83 +26,45 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useAppLink } from "modules/apps/useAppLink"; -import type { FC } from "react"; +import { type FC, useState } from "react"; +import { cn } from "utils/cn"; -const getStatusColor = ( - theme: Theme, - state: APIWorkspaceAppStatus["state"], -) => { +const getStatusColor = (state: APIWorkspaceAppStatus["state"]) => { switch (state) { case "complete": - return theme.palette.success.main; + return "text-content-success"; case "failure": - return theme.palette.error.main; + return "text-content-warning"; case "working": - return theme.palette.primary.main; + return "text-highlight-sky"; default: - // Assuming unknown state maps to warning/secondary visually - return theme.palette.text.secondary; + return "text-content-secondary"; } }; const getStatusIcon = ( - theme: Theme, state: APIWorkspaceAppStatus["state"], isLatest: boolean, + className?: string, ) => { - // Determine color: Use state color if latest, otherwise use disabled text color (grey) - const color = isLatest - ? getStatusColor(theme, state) - : theme.palette.text.disabled; + const iconClassName = cn(["size-[18px]", getStatusColor(state), className]); + switch (state) { case "complete": - return ; + return ; case "failure": - return ; + return ; case "working": - // Use Hourglass for past "working" states, spinner for the current one return isLatest ? ( - + ) : ( - + ); default: - return ; + return ; } }; -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); @@ -134,9 +101,8 @@ const formatURI = (uri: string) => { // --- Component Implementation --- export interface AppStatusesProps { - apps: WorkspaceApp[]; workspace: Workspace; - agents: ReadonlyArray; + agent: WorkspaceAgent; /** Optional reference date for calculating relative time. Defaults to Date.now(). Useful for Storybook. */ referenceDate?: Date; } @@ -148,206 +114,136 @@ interface StatusWithAppInfo extends APIWorkspaceAppStatus { } export const AppStatuses: FC = ({ - apps, workspace, - agents, + agent, referenceDate, }) => { - const theme = useTheme(); - - // 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) - mutating the value is - // fine since it's not an outside parameter - allStatuses.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + const [displayStatuses, setDisplayStatuses] = useState(false); + const allStatuses: StatusWithAppInfo[] = agent.apps.flatMap((app) => + app.statuses + .map((status) => ({ + ...status, + app, + })) + .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; } + const comparisonDate = referenceDate ?? new Date(); + const latestStatus = allStatuses[0]; + const otherStatuses = allStatuses.slice(1); + return ( -
- {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 }); +
+
+
+ + {getStatusIcon(latestStatus.state, true)} + {latestStatus.message} + + + {formatDistance(new Date(latestStatus.created_at), comparisonDate, { + addSuffix: true, + })} + +
- // Get the associated app for this status - const currentApp = status.app; - const agent = agents.find((agent) => agent.id === status.agent_id); +
+ {latestStatus.app && ( + + )} - // Determine if app link should be shown - const showAppLink = - isLatest || - (index > 0 && status.app_id !== allStatuses[index - 1].app_id); + {latestStatus.uri && + (latestStatus.uri.startsWith("file://") ? ( + + + + + + {formatURI(latestStatus.uri)} + + + + This file is located in your workspace + + + + ) : ( + + ))} - return ( -
- {/* Icon Column */} -
- {getStatusIcon(theme, status.state, isLatest) || ( - - )} -
+ + + + + + + {displayStatuses ? "Hide statuses" : "Show statuses"} + + + +
+
- {/* Content Column */} + {displayStatuses && + otherStatuses.map((status) => { + const statusTime = new Date(status.created_at); + const formattedTimestamp = formatDistance( + statusTime, + comparisonDate, + { + addSuffix: true, + }, + ); + + return (
- {/* Message */} -
- {status.message} -
- - {/* Links Row */} -
- {/* Conditional App Link */} - {currentApp && agent && showAppLink && ( - - )} - - {/* Existing URI Link */} - {status.uri && ( -
- {isFileURI ? ( - -
- - {formatURI(status.uri)} -
-
- ) : ( - - -
- {formatURI(status.uri)} -
- - )} -
- )} -
- - {/* Timestamp */} -
- {formattedTimestamp} +
+ + {getStatusIcon(status.state, false, "size-icon-xs w-[18px]")} + {status.message} + + + {formattedTimestamp} +
-
- ); - })} + ); + })}
); }; @@ -360,62 +256,18 @@ type AppLinkProps = { const AppLink: FC = ({ app, agent, workspace }) => { const link = useAppLink(app, { agent, workspace }); - const theme = useTheme(); return ( - - + - {app.icon ? ( - {`${link.label} - ) : ( - - )} - {/* Keep app name short */} - - {link.label} - - - + {app.icon ? : } + {link.label} + + ); }; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index a59e2f78bcee2..7426158ffba40 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -234,6 +234,10 @@ export const RunningWithAppStatuses: Story = { available: 1, }, }, + latest_app_status: { + ...Mocks.MockWorkspaceAppStatus, + agent_id: Mocks.MockWorkspaceAgent.id, + }, }, handleStart: action("start"), handleStop: action("stop"), diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 8c874e71beeb3..6016ca8c423eb 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -4,17 +4,15 @@ 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, useMemo } from "react"; +import type { FC } from "react"; import { useNavigate } from "react-router-dom"; import type { WorkspacePermissions } from "../../modules/workspaces/permissions"; -import { AppStatuses } from "./AppStatuses"; import { HistorySidebar } from "./HistorySidebar"; import { ResourceMetadata } from "./ResourceMetadata"; import { ResourcesSidebar } from "./ResourcesSidebar"; @@ -109,14 +107,6 @@ export const Workspace: FC = ({ 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 (
= ({ )} - {/* Container for Agent Rows + Activity Sidebar */} {selectedResource && ( -
- {/* Left Side: Agent Rows */} -
- {selectedResource.agents - // If an agent has a `parent_id`, that means it is - // child of another agent. We do not want these agents - // to be displayed at the top-level on this page. We - // want them to display _as children_ of their parents. - ?.filter((agent) => agent.parent_id === null) - .map((agent) => ( - - ))} - - {(!selectedResource.agents || - selectedResource.agents?.length === 0) && ( -
-
-

- No agents are currently assigned to this resource. -

-
-
- )} -
+
+ {selectedResource.agents + // If an agent has a `parent_id`, that means it is + // child of another agent. We do not want these agents + // to be displayed at the top-level on this page. We + // want them to display _as children_ of their parents. + ?.filter((agent) => agent.parent_id === null) + .map((agent) => ( + + ))} - {/* Right Side: Activity Box */} - {hasAppStatus && ( + {(!selectedResource.agents || + selectedResource.agents?.length === 0) && (
- {/* Activity Header */} -
-
- Activity -
-
- { - // Calculate total status count - selectedResource.agents - ?.flatMap((agent) => agent.apps ?? []) - .reduce( - (count, app) => count + (app.statuses?.length ?? 0), - 0, - ) - }{" "} - Total -
-
- -
- agent.apps ?? [], - ) as WorkspaceApp[] - } - workspace={workspace} - agents={selectedResource.agents || []} - /> +
+

+ No agents are currently assigned to this resource. +

)} -
+
)}