mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
* Adds support for parameter `format=text` in the following API routes: * `/api/v2/workspaceagents/:id/logs` * `/api/v2/workspacebuilds/:id/logs` * `/api/v2/templateversions/:id/logs` * `/api/v2/templateversions/:id/dry-run/:id/logs` * Adds links to view raw logs on the following pages: * Workspace build page * Template editor page * Template version page * Refactors existing log formatting in `cli/logs.go` to live in `codersdk`. 🤖 Generated with Claude Opus 4.5, reviewed by me. --------- Co-authored-by: Claude <noreply@anthropic.com>
340 lines
9.6 KiB
TypeScript
340 lines
9.6 KiB
TypeScript
import type {
|
|
ProvisionerJobLog,
|
|
WorkspaceAgent,
|
|
WorkspaceBuild,
|
|
} from "api/typesGenerated";
|
|
import { Alert } from "components/Alert/Alert";
|
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
|
import { Loader } from "components/Loader/Loader";
|
|
import { Margins } from "components/Margins/Margins";
|
|
import {
|
|
FullWidthPageHeader,
|
|
PageHeaderSubtitle,
|
|
PageHeaderTitle,
|
|
} from "components/PageHeader/FullWidthPageHeader";
|
|
import { Stack } from "components/Stack/Stack";
|
|
import { Stats, StatsItem } from "components/Stats/Stats";
|
|
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
|
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
|
import { ExternalLinkIcon } from "lucide-react";
|
|
import { BuildAvatar } from "modules/builds/BuildAvatar/BuildAvatar";
|
|
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
|
|
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
|
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
|
import {
|
|
WorkspaceBuildData,
|
|
WorkspaceBuildDataSkeleton,
|
|
} from "modules/workspaces/WorkspaceBuildData/WorkspaceBuildData";
|
|
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
|
import {
|
|
type FC,
|
|
type HTMLProps,
|
|
type ReactNode,
|
|
useLayoutEffect,
|
|
useRef,
|
|
} from "react";
|
|
import { Link } from "react-router";
|
|
import { cn } from "utils/cn";
|
|
import { formatDate } from "utils/time";
|
|
import { displayWorkspaceBuildDuration } from "utils/workspace";
|
|
import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar";
|
|
|
|
export const LOGS_TAB_KEY = "logs";
|
|
|
|
type BuildStatsItemProps = Readonly<{
|
|
children?: ReactNode;
|
|
label: string;
|
|
}>;
|
|
|
|
const BuildStatsItem: FC<BuildStatsItemProps> = ({ children, label }) => {
|
|
return (
|
|
<StatsItem
|
|
className="flex-col gap-0 p-0 [&>span:first-of-type]:text-xs [&>span:first-of-type]:font-medium md:p-0"
|
|
label={label}
|
|
value={children}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface WorkspaceBuildPageViewProps {
|
|
logs: ProvisionerJobLog[] | undefined;
|
|
build: WorkspaceBuild | undefined;
|
|
buildError?: unknown;
|
|
builds: WorkspaceBuild[] | undefined;
|
|
activeBuildNumber: number;
|
|
}
|
|
|
|
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
|
logs,
|
|
build,
|
|
buildError,
|
|
builds,
|
|
activeBuildNumber,
|
|
}) => {
|
|
const tabState = useSearchParamsKey({
|
|
key: LOGS_TAB_KEY,
|
|
defaultValue: "build",
|
|
});
|
|
|
|
if (buildError) {
|
|
return (
|
|
<Margins>
|
|
<ErrorAlert error={buildError} className="my-4" />
|
|
</Margins>
|
|
);
|
|
}
|
|
|
|
if (!build) {
|
|
return <Loader />;
|
|
}
|
|
|
|
const agents = build.resources.flatMap((r) => r.agents ?? []);
|
|
const selectedAgent = agents.find((a) => a.id === tabState.value);
|
|
|
|
return (
|
|
<DashboardFullPage>
|
|
<FullWidthPageHeader sticky={false}>
|
|
<Stack direction="row">
|
|
<BuildAvatar build={build} size="lg" />
|
|
<div>
|
|
<PageHeaderTitle>Build #{build.build_number}</PageHeaderTitle>
|
|
<PageHeaderSubtitle>{build.initiator_name}</PageHeaderSubtitle>
|
|
</div>
|
|
</Stack>
|
|
|
|
<Stats
|
|
aria-label="Build details"
|
|
className="flex flex-col items-start gap-2 px-0 border-none grow basis-0 md:flex-row md:gap-x-12 md:gap-y-6"
|
|
>
|
|
<BuildStatsItem label="Workspace">
|
|
<Link
|
|
to={`/@${build.workspace_owner_name}/${build.workspace_name}`}
|
|
>
|
|
{build.workspace_name}
|
|
</Link>
|
|
</BuildStatsItem>
|
|
<BuildStatsItem label="Template version">
|
|
{build.template_version_name}
|
|
</BuildStatsItem>
|
|
<BuildStatsItem label="Duration">
|
|
{displayWorkspaceBuildDuration(build)}
|
|
</BuildStatsItem>
|
|
<BuildStatsItem label="Started at">
|
|
{formatDate(new Date(build.created_at))}
|
|
</BuildStatsItem>
|
|
<BuildStatsItem label="Action">
|
|
<span className="capitalize">{build.transition}</span>
|
|
</BuildStatsItem>
|
|
</Stats>
|
|
</FullWidthPageHeader>
|
|
|
|
<div className="flex items-start overflow-hidden grow basis-0">
|
|
<Sidebar>
|
|
<SidebarCaption>Builds</SidebarCaption>
|
|
{!builds &&
|
|
Array.from({ length: 15 }, (_, i) => (
|
|
<SidebarItem key={i}>
|
|
<WorkspaceBuildDataSkeleton />
|
|
</SidebarItem>
|
|
))}
|
|
|
|
{builds?.map((build) => (
|
|
<Link
|
|
key={build.id}
|
|
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
|
|
>
|
|
<SidebarItem active={build.build_number === activeBuildNumber}>
|
|
<WorkspaceBuildData build={build} />
|
|
</SidebarItem>
|
|
</Link>
|
|
))}
|
|
</Sidebar>
|
|
|
|
<ScrollArea>
|
|
<div className="flex items-center justify-between border-b border-solid border-border">
|
|
<Tabs active={tabState.value}>
|
|
<TabsList className="gap-0">
|
|
<TabLink
|
|
to={`?${LOGS_TAB_KEY}=build`}
|
|
value="build"
|
|
className="px-6 pb-2"
|
|
>
|
|
Build
|
|
</TabLink>
|
|
|
|
{agents.map((a) => (
|
|
<TabLink
|
|
className="px-6 pb-2"
|
|
to={`?${LOGS_TAB_KEY}=${a.id}`}
|
|
value={a.id}
|
|
key={a.id}
|
|
>
|
|
coder_agent.{a.name}
|
|
</TabLink>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
{tabState.value === "build" && (
|
|
<a
|
|
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 px-4 pb-2 text-xs text-content-secondary hover:text-content-primary"
|
|
>
|
|
View raw logs
|
|
<ExternalLinkIcon className="size-3" />
|
|
</a>
|
|
)}
|
|
{tabState.value !== "build" && selectedAgent && (
|
|
<a
|
|
href={`/api/v2/workspaceagents/${selectedAgent.id}/logs?format=text`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 px-4 pb-2 text-xs text-content-secondary hover:text-content-primary"
|
|
>
|
|
View raw logs
|
|
<ExternalLinkIcon className="size-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
{build.transition === "delete" && build.job.status === "failed" && (
|
|
<Alert
|
|
severity="error"
|
|
prominent
|
|
className="rounded-none border-0 border-b border-solid border-border"
|
|
>
|
|
<div>
|
|
The workspace may have failed to delete due to a Terraform state
|
|
mismatch. A template admin may run{" "}
|
|
<code className="font-semibold w-fit inline-block">
|
|
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
|
|
</code>{" "}
|
|
to delete the workspace skipping resource destruction.
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
|
|
{build?.job?.logs_overflowed && (
|
|
<Alert
|
|
severity="warning"
|
|
prominent
|
|
className="rounded-none border-0 border-b border-solid border-border"
|
|
>
|
|
Provisioner logs exceeded the max size of 1MB. Will not continue
|
|
to write provisioner logs for workspace build.
|
|
</Alert>
|
|
)}
|
|
|
|
{tabState.value === "build" && (
|
|
<BuildLogsContent logs={logs} build={build} />
|
|
)}
|
|
{tabState.value !== "build" && selectedAgent && (
|
|
<AgentLogsContent agent={selectedAgent} />
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
</DashboardFullPage>
|
|
);
|
|
};
|
|
|
|
const ScrollArea: FC<HTMLProps<HTMLDivElement>> = ({ className, ...props }) => {
|
|
/**
|
|
* @todo 2024-10-03 - Use only CSS to set the height of the content.
|
|
*
|
|
* On Safari, when content is rendered inside a flex container and needs to
|
|
* scroll, the parent container must have a height set. Achieving this may
|
|
* require significant refactoring of the layout components where we
|
|
* currently use height and min-height set to 100%.
|
|
*
|
|
* @see {@link https://github.com/coder/coder/issues/9687}
|
|
* @see {@link https://stackoverflow.com/questions/43381836/height100-works-in-chrome-but-not-in-safari}
|
|
*/
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
useLayoutEffect(() => {
|
|
const contentEl = contentRef.current;
|
|
if (!contentEl) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* 2025-09-17 - We're updating the height directly to minimize the
|
|
* overhead in React itself. There is a risk down the line that the
|
|
* height value will be wiped on re-renders, but that seemed like a
|
|
* small enough risk that it wasn't worth accounting for just yet
|
|
*/
|
|
const syncParentSize = () => {
|
|
const parentEl = contentEl.parentElement;
|
|
if (parentEl && contentEl) {
|
|
contentEl.style.height = `${contentEl.parentElement.clientHeight}px`;
|
|
}
|
|
};
|
|
|
|
syncParentSize();
|
|
const resizeObserver = new ResizeObserver(syncParentSize);
|
|
resizeObserver.observe(document.body);
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={contentRef}
|
|
className={cn("overflow-y-auto w-full", className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
};
|
|
|
|
function sortLogsByCreatedAt(
|
|
logs: readonly ProvisionerJobLog[],
|
|
): ProvisionerJobLog[] {
|
|
return [...logs].sort((a, b) => {
|
|
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
});
|
|
}
|
|
|
|
const BuildLogsContent: FC<{
|
|
logs?: ProvisionerJobLog[];
|
|
build?: WorkspaceBuild;
|
|
}> = ({ logs = [], build }) => {
|
|
if (!logs) {
|
|
return <Loader />;
|
|
}
|
|
|
|
return (
|
|
<WorkspaceBuildLogs
|
|
// logs header class adds extra spacing to the first log header to
|
|
// prevent it from being too close to the tabs
|
|
className="border-none [&_.logs-header:first-of-type]:pt-4"
|
|
style={{ "--log-line-side-padding": `${TAB_PADDING_X}px` }}
|
|
build={build}
|
|
logs={sortLogsByCreatedAt(logs)}
|
|
disableAutoscroll
|
|
/>
|
|
);
|
|
};
|
|
|
|
type AgentLogsContentProps = {
|
|
agent: WorkspaceAgent;
|
|
};
|
|
|
|
const AgentLogsContent: FC<AgentLogsContentProps> = ({ agent }) => {
|
|
const logs = useAgentLogs({ agentId: agent.id });
|
|
return (
|
|
<AgentLogs
|
|
overflowed={agent.logs_overflowed}
|
|
sources={agent.log_sources}
|
|
height={560}
|
|
width="100%"
|
|
logs={logs.map((l) => ({
|
|
id: l.id,
|
|
output: l.output,
|
|
time: l.created_at,
|
|
level: l.level,
|
|
sourceId: l.source_id,
|
|
}))}
|
|
/>
|
|
);
|
|
};
|