mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement tabs for agent logs (#23952)
This PR adds log source tabs to the workspace agent logs panel so users can quickly focus on specific log streams instead of scanning one combined feed. It also updates the shared tabs trigger behavior to explicitly use `type="button"` when rendered as a native button, preventing unintended form submission behavior. - Adds per-source tabs in `AgentRow` with an **All Logs** default view. - Shows only log sources that currently have output, with source icons and sorted labels. - Filters rendered log lines based on the active tab while preserving existing log streaming/scroll behavior. - Refines the logs container layout/styling for the new tabbed UI. - Updates `TabsTrigger` to safely default button type when not using `asChild`. https://github.com/user-attachments/assets/9b3e7a9d-72e3-4c12-aba2-2b70cdbc04c1
This commit is contained in:
@@ -66,9 +66,15 @@ export const TabsList: FC<TabsListProps> = ({
|
||||
|
||||
type TabsTriggerProps = ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
export const TabsTrigger: FC<TabsTriggerProps> = ({ ...props }) => {
|
||||
export const TabsTrigger: FC<TabsTriggerProps> = ({
|
||||
type: triggerType = "button",
|
||||
...props
|
||||
}) => {
|
||||
const type = props.asChild ? undefined : triggerType;
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
type={type}
|
||||
className={cn(
|
||||
"border-none py-3 bg-transparent",
|
||||
"text-inherit font-normal text-sm",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { spyOn, userEvent, within } from "storybook/test";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import { workspaceAgentContainersKey } from "#/api/queries/workspaces";
|
||||
import type { WorkspaceAgentLogSource } from "#/api/typesGenerated";
|
||||
import { getPreferredProxy } from "#/contexts/ProxyContext";
|
||||
import { chromatic } from "#/testHelpers/chromatic";
|
||||
import * as M from "#/testHelpers/entities";
|
||||
@@ -89,6 +90,36 @@ const logs = [
|
||||
created_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const installScriptLogSource: WorkspaceAgentLogSource = {
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "f2ee4b8d-b09d-4f4e-a1f1-5e4adf7d53bb",
|
||||
display_name: "Install Script",
|
||||
};
|
||||
|
||||
const tabbedLogs = [
|
||||
{
|
||||
id: 100,
|
||||
level: "info",
|
||||
output: "startup: preparing workspace",
|
||||
source_id: M.MockWorkspaceAgentLogSource.id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
level: "info",
|
||||
output: "install: pnpm install",
|
||||
source_id: installScriptLogSource.id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
level: "info",
|
||||
output: "install: setup complete",
|
||||
source_id: installScriptLogSource.id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof AgentRow> = {
|
||||
title: "components/AgentRow",
|
||||
component: AgentRow,
|
||||
@@ -334,3 +365,40 @@ export const FoundDevcontainer: Story = {
|
||||
webSocket: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const LogsTabs: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...M.MockWorkspaceAgentReady,
|
||||
logs_length: tabbedLogs.length,
|
||||
log_sources: [M.MockWorkspaceAgentLogSource, installScriptLogSource],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
webSocket: [
|
||||
{
|
||||
event: "message",
|
||||
data: JSON.stringify(tabbedLogs),
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Logs" }));
|
||||
|
||||
const installTab = await canvas.findByRole("tab", {
|
||||
name: "Install Script",
|
||||
});
|
||||
await userEvent.click(installTab);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(installTab).toHaveAttribute("data-state", "active"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByText("startup: preparing workspace"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
await expect(canvas.getByText("install: pnpm install")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { SquareCheckBigIcon } from "lucide-react";
|
||||
import { PlayIcon, SquareCheckBigIcon } from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
@@ -21,6 +21,14 @@ import type {
|
||||
} from "#/api/typesGenerated";
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import type { Line } from "#/components/Logs/LogLine";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "#/components/Tabs/Tabs";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses";
|
||||
@@ -89,6 +97,8 @@ const getAgentBorderClass = (
|
||||
);
|
||||
};
|
||||
|
||||
const STARTUP_SCRIPT_DISPLAY_NAME = "Startup Script";
|
||||
|
||||
export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
subAgents,
|
||||
@@ -185,11 +195,65 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
Boolean(hasDevcontainerErrors || shouldShowWildcardWarning),
|
||||
);
|
||||
|
||||
const [selectedLogTab, setSelectedLogTab] = useState("all");
|
||||
const sourceLogTabs = agent.log_sources
|
||||
.filter((logSource) => {
|
||||
// Remove the logSources that have no entries.
|
||||
return agentLogs.some(
|
||||
(log) =>
|
||||
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
|
||||
);
|
||||
})
|
||||
.map((logSource) => ({
|
||||
// Show the icon for the log source if it has one.
|
||||
// In the startup script case, we show a bespoke play icon.
|
||||
startIcon: logSource.icon ? (
|
||||
<ExternalImage
|
||||
src={logSource.icon}
|
||||
alt=""
|
||||
className="size-icon-xs shrink-0"
|
||||
/>
|
||||
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
|
||||
<PlayIcon className="size-icon-xs shrink-0" />
|
||||
) : null,
|
||||
title: logSource.display_name,
|
||||
value: logSource.id,
|
||||
}));
|
||||
const startupScriptLogTab = sourceLogTabs.find(
|
||||
(tab) => tab.title === STARTUP_SCRIPT_DISPLAY_NAME,
|
||||
);
|
||||
const sortedSourceLogTabs = sourceLogTabs
|
||||
.filter((tab) => tab !== startupScriptLogTab)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const logTabs: {
|
||||
startIcon?: React.ReactNode;
|
||||
title: string;
|
||||
value: string;
|
||||
}[] = [
|
||||
{
|
||||
title: "All Logs",
|
||||
value: "all",
|
||||
},
|
||||
...(startupScriptLogTab ? [startupScriptLogTab] : []),
|
||||
...sortedSourceLogTabs,
|
||||
];
|
||||
const selectedLogs =
|
||||
selectedLogTab === "all"
|
||||
? agentLogs
|
||||
: agentLogs.filter((log) => log.source_id === selectedLogTab);
|
||||
const selectedLogLines: readonly Line[] = selectedLogs.map((log) => ({
|
||||
id: log.id,
|
||||
output: log.output,
|
||||
time: log.created_at,
|
||||
level: log.level,
|
||||
sourceId: log.source_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex max-w-full flex-col rounded-lg border border-solid bg-surface-primary text-sm shadow-md",
|
||||
"flex max-w-full flex-col rounded-lg border border-solid bg-surface-primary text-sm shadow-md overflow-clip",
|
||||
borderClass,
|
||||
)}
|
||||
>
|
||||
@@ -347,30 +411,6 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
|
||||
{hasStartupFeatures && (
|
||||
<section className="border-0 border-t border-solid border-border">
|
||||
<Collapse in={showLogs}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<AgentLogs
|
||||
ref={logListRef}
|
||||
innerRef={logListDivRef}
|
||||
height={256}
|
||||
width={width}
|
||||
className="max-h-[420px] border-0 border-b border-solid border-border"
|
||||
onScroll={handleLogScroll}
|
||||
overflowed={agent.logs_overflowed}
|
||||
logs={agentLogs.map((l) => ({
|
||||
id: l.id,
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
sourceId: l.source_id,
|
||||
time: l.created_at,
|
||||
}))}
|
||||
sources={agent.log_sources}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Collapse>
|
||||
|
||||
<div className="flex flex-row gap-2 px-4 py-3">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -383,6 +423,47 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
<Divider orientation="vertical" variant="middle" flexItem />
|
||||
<DownloadAgentLogsButton agent={agent} />
|
||||
</div>
|
||||
<Collapse in={showLogs}>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="border border-solid rounded-md overflow-clip">
|
||||
<Tabs
|
||||
className="-mx-px -mt-px"
|
||||
value={selectedLogTab}
|
||||
onValueChange={setSelectedLogTab}
|
||||
>
|
||||
<TabsList variant="insideBox">
|
||||
{logTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.startIcon}
|
||||
<span>{tab.title}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{/*
|
||||
Using a singular TabsContent is necessary to avoid scrolling
|
||||
issues when the selected log tab changes.
|
||||
*/}
|
||||
<TabsContent value={selectedLogTab}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<AgentLogs
|
||||
ref={logListRef}
|
||||
innerRef={logListDivRef}
|
||||
height={256}
|
||||
width={width}
|
||||
onScroll={handleLogScroll}
|
||||
logs={selectedLogLines}
|
||||
sources={agent.log_sources}
|
||||
overflowed={agent.logs_overflowed}
|
||||
className="bg-transparent"
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user