mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor: refactor activity column in the workspaces table (#17976)
The goal is to better integrate the activity column data with the existent data: - Make the message one line, the full message is in the tooltip, and display the state at the bottom. This way, it is visually consistent with the other columns like status, name and template. - Moved the app, and uri, to the actions column, instead of showing them together with the message in the activity column. **Previous:** <img width="1512" alt="Screenshot 2025-05-21 at 17 28 46" src="https://github.com/user-attachments/assets/ea9188a5-d82e-416c-b961-edf0104f66c6" /> **After:** <img width="1512" alt="Screenshot 2025-05-21 at 17 28 57" src="https://github.com/user-attachments/assets/f50dbe82-cd3e-4448-9fa2-bde9193166d6" />
This commit is contained in:
@@ -2,9 +2,6 @@ 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";
|
||||
@@ -68,24 +65,6 @@ export const Working: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
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: {
|
||||
@@ -95,14 +74,3 @@ export const LongMessage: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithApp: Story = {
|
||||
args: {
|
||||
status: MockWorkspaceAppStatus,
|
||||
app: {
|
||||
...MockWorkspaceApp,
|
||||
},
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,305 +1,54 @@
|
||||
import type { Theme } from "@emotion/react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import type {
|
||||
WorkspaceAppStatus as APIWorkspaceAppStatus,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
WorkspaceAppStatusState,
|
||||
} from "api/typesGenerated";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
LayoutGridIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { useAppLink } from "modules/apps/useAppLink";
|
||||
import type { FC } from "react";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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 <CircleCheckIcon className="size-icon-xs" style={{ color }} />;
|
||||
case "failure":
|
||||
return <CircleAlertIcon className="size-icon-xs" style={{ color }} />;
|
||||
case "working":
|
||||
return <CircularProgress size={16} sx={{ color }} />;
|
||||
default:
|
||||
return <TriangleAlertIcon className="size-icon-xs" style={{ color }} />;
|
||||
}
|
||||
const iconByState: Record<WorkspaceAppStatusState, ReactNode> = {
|
||||
complete: (
|
||||
<CircleCheckIcon className="size-4 shrink-0 text-content-success" />
|
||||
),
|
||||
failure: <CircleAlertIcon className="size-4 shrink-0 text-content-warning" />,
|
||||
working: <Spinner size="sm" className="shrink-0" loading />,
|
||||
};
|
||||
|
||||
export const WorkspaceAppStatus = ({
|
||||
workspace,
|
||||
status,
|
||||
agent,
|
||||
app,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
status?: APIWorkspaceAppStatus | null;
|
||||
app?: WorkspaceApp;
|
||||
agent?: WorkspaceAgent;
|
||||
status: APIWorkspaceAppStatus | null;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const commonStyles = useCommonStyles();
|
||||
|
||||
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>
|
||||
<span className="text-content-disabled text-sm">
|
||||
-<span className="sr-only">No activity</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isFileURI = status.uri?.startsWith("file://");
|
||||
|
||||
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 && agent && (
|
||||
<AppLink app={app} workspace={workspace} agent={agent} />
|
||||
)}
|
||||
{status.uri && (
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{isFileURI ? (
|
||||
<div
|
||||
css={{
|
||||
...commonStyles,
|
||||
}}
|
||||
>
|
||||
<FileIcon
|
||||
className="size-icon-xs"
|
||||
css={{
|
||||
opacity: 0.5,
|
||||
marginRight: "0.25rem",
|
||||
}}
|
||||
/>
|
||||
<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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon
|
||||
className="size-icon-xs"
|
||||
css={{
|
||||
opacity: 0.7,
|
||||
flexShrink: 0,
|
||||
marginRight: 2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
css={{
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
color: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatURI(status.uri)}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
{iconByState[status.state]}
|
||||
<span className="whitespace-nowrap max-w-72 overflow-hidden text-ellipsis text-sm text-content-primary font-medium">
|
||||
{status.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{status.message}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="first-letter:uppercase block pl-6">{status.state}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AppLinkProps = {
|
||||
app: WorkspaceApp;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
const theme = useTheme();
|
||||
const commonStyles = useCommonStyles();
|
||||
const link = useAppLink(app, { agent, workspace });
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LayoutGridIcon
|
||||
className="size-icon-xs"
|
||||
css={{
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{app.display_name}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const useCommonStyles = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import { StarIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileIcon, StarIcon } from "lucide-react";
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import {
|
||||
BanIcon,
|
||||
@@ -138,16 +138,22 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
) || {}
|
||||
);
|
||||
}, [workspaces]);
|
||||
const hasAppStatus = useMemo(
|
||||
const hasActivity = useMemo(
|
||||
() => Object.keys(workspaceIDToAppByStatus).length > 0,
|
||||
[workspaceIDToAppByStatus],
|
||||
);
|
||||
const tableColumnSize = {
|
||||
name: "w-2/6",
|
||||
template: hasActivity ? "w-1/6" : "w-2/6",
|
||||
status: hasActivity ? "w-1/6" : "w-2/6",
|
||||
activity: "w-2/6",
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={hasAppStatus ? "w-1/6" : "w-2/6"}>
|
||||
<TableHead className={tableColumnSize.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCheckWorkspaces && (
|
||||
<Checkbox
|
||||
@@ -171,10 +177,14 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
Name
|
||||
</div>
|
||||
</TableHead>
|
||||
{hasAppStatus && <TableHead className="w-2/6">Activity</TableHead>}
|
||||
<TableHead className="w-2/6">Template</TableHead>
|
||||
<TableHead className="w-2/6">Status</TableHead>
|
||||
<TableHead className="w-0" />
|
||||
<TableHead className={tableColumnSize.template}>Template</TableHead>
|
||||
<TableHead className={tableColumnSize.status}>Status</TableHead>
|
||||
{hasActivity && (
|
||||
<TableHead className={tableColumnSize.activity}>Activity</TableHead>
|
||||
)}
|
||||
<TableHead className="w-0">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_td]:h-[72px]">
|
||||
@@ -229,7 +239,9 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
<AvatarData
|
||||
title={
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{workspace.name}
|
||||
<span className="whitespace-nowrap">
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.favorite && (
|
||||
<StarIcon className="size-icon-xs" />
|
||||
)}
|
||||
@@ -255,20 +267,13 @@ 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>
|
||||
<AvatarData
|
||||
title={getDisplayWorkspaceTemplateName(workspace)}
|
||||
title={
|
||||
<span className="whitespace-nowrap block max-w-52 text-ellipsis overflow-hidden">
|
||||
{getDisplayWorkspaceTemplateName(workspace)}
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
dashboard.showOrganizations && (
|
||||
<>
|
||||
@@ -290,6 +295,12 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
|
||||
<WorkspaceStatusCell workspace={workspace} />
|
||||
|
||||
{hasActivity && (
|
||||
<TableCell>
|
||||
<WorkspaceAppStatus status={workspace.latest_app_status} />
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<WorkspaceActionsCell
|
||||
workspace={workspace}
|
||||
onActionSuccess={onActionSuccess}
|
||||
@@ -399,7 +410,7 @@ const WorkspaceStatusCell: FC<WorkspaceStatusCellProps> = ({ workspace }) => {
|
||||
<WorkspaceDormantBadge workspace={workspace} />
|
||||
)}
|
||||
</WorkspaceStatusIndicator>
|
||||
<span className="text-xs font-medium text-content-secondary ml-6">
|
||||
<span className="text-xs font-medium text-content-secondary ml-6 whitespace-nowrap">
|
||||
{lastUsedMessage(workspace.last_used_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -504,9 +515,12 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1 justify-end">
|
||||
{workspace.latest_build.status === "running" && (
|
||||
<WorkspaceApps workspace={workspace} />
|
||||
)}
|
||||
{workspace.latest_build.status === "running" &&
|
||||
(workspace.latest_app_status ? (
|
||||
<WorkspaceAppStatusLinks workspace={workspace} />
|
||||
) : (
|
||||
<WorkspaceApps workspace={workspace} />
|
||||
))}
|
||||
|
||||
{abilities.actions.includes("start") && (
|
||||
<PrimaryAction
|
||||
@@ -701,6 +715,38 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
|
||||
return buttons;
|
||||
};
|
||||
|
||||
type WorkspaceAppStatusLinksProps = {
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const WorkspaceAppStatusLinks: FC<WorkspaceAppStatusLinksProps> = ({
|
||||
workspace,
|
||||
}) => {
|
||||
const status = workspace.latest_app_status;
|
||||
const agent = workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.find((a) => a?.id === status?.agent_id);
|
||||
const app = agent?.apps.find((a) => a.id === status?.app_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{agent && app && (
|
||||
<IconAppLink app={app} workspace={workspace} agent={agent} />
|
||||
)}
|
||||
|
||||
{status?.uri && status?.uri !== "n/a" && (
|
||||
<BaseIconLink label={status.uri} href={status.uri} target="_blank">
|
||||
{status.uri.startsWith("file://") ? (
|
||||
<FileIcon />
|
||||
) : (
|
||||
<ExternalLinkIcon />
|
||||
)}
|
||||
</BaseIconLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type IconAppLinkProps = {
|
||||
app: WorkspaceApp;
|
||||
workspace: Workspace;
|
||||
@@ -730,6 +776,7 @@ type BaseIconLinkProps = PropsWithChildren<{
|
||||
href: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
target?: string;
|
||||
}>;
|
||||
|
||||
const BaseIconLink: FC<BaseIconLinkProps> = ({
|
||||
@@ -737,6 +784,7 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
|
||||
isLoading,
|
||||
label,
|
||||
children,
|
||||
target,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
@@ -745,6 +793,7 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon-lg" asChild>
|
||||
<a
|
||||
target={target}
|
||||
className={isLoading ? "animate-pulse" : ""}
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user