mirror of
https://github.com/coder/coder.git
synced 2026-06-04 05:28:20 +00:00
286 lines
7.8 KiB
TypeScript
286 lines
7.8 KiB
TypeScript
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
|
import type { FC } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import type {
|
|
ProvisionerJobLog,
|
|
WorkspaceAgent,
|
|
WorkspaceBuild,
|
|
} from "api/typesGenerated";
|
|
import { Alert } from "components/Alert/Alert";
|
|
import { BuildAvatar } from "components/BuildAvatar/BuildAvatar";
|
|
import { Loader } from "components/Loader/Loader";
|
|
import {
|
|
FullWidthPageHeader,
|
|
PageHeaderTitle,
|
|
PageHeaderSubtitle,
|
|
} 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 { DashboardFullPage } from "modules/dashboard/DashboardLayout";
|
|
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
|
import { useAgentLogs } from "modules/resources/AgentLogs/useAgentLogs";
|
|
import {
|
|
WorkspaceBuildData,
|
|
WorkspaceBuildDataSkeleton,
|
|
} from "modules/workspaces/WorkspaceBuildData/WorkspaceBuildData";
|
|
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
|
import { displayWorkspaceBuildDuration } from "utils/workspace";
|
|
import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar";
|
|
|
|
export const LOGS_TAB_KEY = "logs";
|
|
|
|
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
|
|
return [...logs].sort(
|
|
(a, b) =>
|
|
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
);
|
|
};
|
|
|
|
export interface WorkspaceBuildPageViewProps {
|
|
logs: ProvisionerJobLog[] | undefined;
|
|
build: WorkspaceBuild | undefined;
|
|
builds: WorkspaceBuild[] | undefined;
|
|
activeBuildNumber: number;
|
|
}
|
|
|
|
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
|
logs,
|
|
build,
|
|
builds,
|
|
activeBuildNumber,
|
|
}) => {
|
|
const theme = useTheme();
|
|
const tabState = useSearchParamsKey({
|
|
key: LOGS_TAB_KEY,
|
|
defaultValue: "build",
|
|
});
|
|
|
|
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" alignItems="center" spacing={3}>
|
|
<BuildAvatar build={build} />
|
|
<div>
|
|
<PageHeaderTitle>Build #{build.build_number}</PageHeaderTitle>
|
|
<PageHeaderSubtitle>{build.initiator_name}</PageHeaderSubtitle>
|
|
</div>
|
|
</Stack>
|
|
|
|
<Stats aria-label="Build details" css={styles.stats}>
|
|
<StatsItem
|
|
css={styles.statsItem}
|
|
label="Workspace"
|
|
value={
|
|
<Link
|
|
to={`/@${build.workspace_owner_name}/${build.workspace_name}`}
|
|
>
|
|
{build.workspace_name}
|
|
</Link>
|
|
}
|
|
/>
|
|
<StatsItem
|
|
css={styles.statsItem}
|
|
label="Template version"
|
|
value={build.template_version_name}
|
|
/>
|
|
<StatsItem
|
|
css={styles.statsItem}
|
|
label="Duration"
|
|
value={displayWorkspaceBuildDuration(build)}
|
|
/>
|
|
<StatsItem
|
|
css={styles.statsItem}
|
|
label="Started at"
|
|
value={new Date(build.created_at).toLocaleString()}
|
|
/>
|
|
<StatsItem
|
|
css={styles.statsItem}
|
|
label="Action"
|
|
value={
|
|
<span css={{ textTransform: "capitalize" }}>
|
|
{build.transition}
|
|
</span>
|
|
}
|
|
/>
|
|
</Stats>
|
|
</FullWidthPageHeader>
|
|
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "start",
|
|
overflow: "hidden",
|
|
flex: 1,
|
|
flexBasis: 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>
|
|
|
|
<div css={{ height: "100%", overflowY: "auto", width: "100%" }}>
|
|
<Tabs active={tabState.value}>
|
|
<TabsList>
|
|
<TabLink to={`?${LOGS_TAB_KEY}=build`} value="build">
|
|
Build
|
|
</TabLink>
|
|
|
|
{agents.map((a) => (
|
|
<TabLink
|
|
to={`?${LOGS_TAB_KEY}=${a.id}`}
|
|
value={a.id}
|
|
key={a.id}
|
|
>
|
|
coder_agent.{a.name}
|
|
</TabLink>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
{build.transition === "delete" && build.job.status === "failed" && (
|
|
<Alert
|
|
severity="error"
|
|
css={{
|
|
borderRadius: 0,
|
|
border: 0,
|
|
background: theme.roles.error.background,
|
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
<div>
|
|
The workspace may have failed to delete due to a Terraform state
|
|
mismatch. A template admin may run{" "}
|
|
<code
|
|
css={{
|
|
display: "inline-block",
|
|
width: "fit-content",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{`coder rm ${
|
|
build.workspace_owner_name + "/" + build.workspace_name
|
|
} --orphan`}
|
|
</code>{" "}
|
|
to delete the workspace skipping resource destruction.
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
|
|
{tabState.value === "build" ? (
|
|
<BuildLogsContent logs={logs} />
|
|
) : (
|
|
<AgentLogsContent
|
|
workspaceId={build.workspace_id}
|
|
agent={selectedAgent!}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DashboardFullPage>
|
|
);
|
|
};
|
|
|
|
const BuildLogsContent: FC<{ logs?: ProvisionerJobLog[] }> = ({ logs }) => {
|
|
if (!logs) {
|
|
return <Loader />;
|
|
}
|
|
|
|
return (
|
|
<WorkspaceBuildLogs
|
|
css={{
|
|
border: 0,
|
|
"--log-line-side-padding": `${TAB_PADDING_X}px`,
|
|
// Add extra spacing to the first log header to prevent it from being
|
|
// too close to the tabs
|
|
"& .logs-header:first-of-type": {
|
|
paddingTop: 16,
|
|
},
|
|
}}
|
|
logs={sortLogsByCreatedAt(logs)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const AgentLogsContent: FC<{ workspaceId: string; agent: WorkspaceAgent }> = ({
|
|
agent,
|
|
workspaceId,
|
|
}) => {
|
|
const logs = useAgentLogs({
|
|
workspaceId,
|
|
agentId: agent.id,
|
|
agentLifeCycleState: agent.lifecycle_state,
|
|
});
|
|
|
|
if (!logs) {
|
|
return <Loader />;
|
|
}
|
|
|
|
return (
|
|
<AgentLogs
|
|
sources={agent.log_sources}
|
|
logs={logs.map((l) => ({
|
|
id: l.id,
|
|
output: l.output,
|
|
time: l.created_at,
|
|
level: l.level,
|
|
sourceId: l.source_id,
|
|
}))}
|
|
height={560}
|
|
width="100%"
|
|
/>
|
|
);
|
|
};
|
|
|
|
const styles = {
|
|
stats: (theme) => ({
|
|
padding: 0,
|
|
border: 0,
|
|
gap: 48,
|
|
rowGap: 24,
|
|
flex: 1,
|
|
|
|
[theme.breakpoints.down("md")]: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
gap: 8,
|
|
},
|
|
}),
|
|
|
|
statsItem: {
|
|
flexDirection: "column",
|
|
gap: 0,
|
|
padding: 0,
|
|
|
|
"& > span:first-of-type": {
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
},
|
|
},
|
|
} satisfies Record<string, Interpolation<Theme>>;
|