mirror of
https://github.com/coder/coder.git
synced 2025-07-21 01:28:49 +00:00
feat: add frontend for app statuses (#17178)
Check out the stories for the exacts...  
This commit is contained in:
@ -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,
|
||||
},
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
207
site/src/pages/WorkspacePage/AppStatuses.stories.tsx
Normal file
207
site/src/pages/WorkspacePage/AppStatuses.stories.tsx
Normal 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
|
||||
},
|
||||
};
|
406
site/src/pages/WorkspacePage/AppStatuses.tsx
Normal file
406
site/src/pages/WorkspacePage/AppStatuses.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user