mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): replace agent log service (#9814)
This commit is contained in:
+2
-2
@@ -1392,7 +1392,7 @@ export const watchBuildLogsByTemplateVersionId = (
|
||||
type WatchWorkspaceAgentLogsOptions = {
|
||||
after: number;
|
||||
onMessage: (logs: TypesGen.WorkspaceAgentLog[]) => void;
|
||||
onDone: () => void;
|
||||
onDone?: () => void;
|
||||
onError: (error: Error) => void;
|
||||
};
|
||||
|
||||
@@ -1423,7 +1423,7 @@ export const watchWorkspaceAgentLogs = (
|
||||
onError(new Error("socket errored"));
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
onDone();
|
||||
onDone && onDone();
|
||||
});
|
||||
|
||||
return socket;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
MockWorkspaceApp,
|
||||
MockProxyLatencies,
|
||||
} from "testHelpers/entities";
|
||||
import { AgentRow } from "./AgentRow";
|
||||
import { AgentRow, LineWithID } from "./AgentRow";
|
||||
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
@@ -84,24 +84,27 @@ const defaultAgentMetadata = [
|
||||
},
|
||||
];
|
||||
|
||||
const storybookLogs: LineWithID[] = [
|
||||
"\x1b[91mCloning Git repository...",
|
||||
"\x1b[2;37;41mStarting Docker Daemon...",
|
||||
"\x1b[1;95mAdding some 🧙magic🧙...",
|
||||
"Starting VS Code...",
|
||||
"\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238",
|
||||
].map((line, index) => ({
|
||||
id: index,
|
||||
level: "info",
|
||||
output: line,
|
||||
time: "",
|
||||
}));
|
||||
|
||||
const meta: Meta<typeof AgentRow> = {
|
||||
title: "components/AgentRow",
|
||||
component: AgentRow,
|
||||
args: {
|
||||
storybookLogs: [
|
||||
"\x1b[91mCloning Git repository...",
|
||||
"\x1b[2;37;41mStarting Docker Daemon...",
|
||||
"\x1b[1;95mAdding some 🧙magic🧙...",
|
||||
"Starting VS Code...",
|
||||
"\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238",
|
||||
].map((line, index) => ({
|
||||
id: index,
|
||||
level: "info",
|
||||
output: line,
|
||||
time: "",
|
||||
})),
|
||||
storybookLogs,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
logs_length: storybookLogs.length,
|
||||
startup_script:
|
||||
'set -eux -o pipefail\n\n# install and start code-server\ncurl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.8.3\n/tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 &\n\n\nif [ ! -d ~/coder ]; then\n mkdir -p ~/coder\n\n git clone https://github.com/coder/coder ~/coder\nfi\n\nsudo service docker start\nDOTFILES_URI=" "\nrm -f ~/.personalize.log\nif [ -n "${DOTFILES_URI// }" ]; then\n coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee -a ~/.personalize.log\nfi\nif [ -x ~/personalize ]; then\n ~/personalize 2>&1 | tee -a ~/.personalize.log\nelif [ -f ~/personalize ]; then\n echo "~/personalize is not executable, skipping..." | tee -a ~/.personalize.log\nfi\n',
|
||||
},
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import Popover from "@mui/material/Popover";
|
||||
import { makeStyles, useTheme } from "@mui/styles";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import * as API from "api/api";
|
||||
import CodeOutlined from "@mui/icons-material/CodeOutlined";
|
||||
import {
|
||||
CloseDropdown,
|
||||
OpenDropdown,
|
||||
} from "components/DropdownArrows/DropdownArrows";
|
||||
import { LogLine, logLineHeight } from "components/WorkspaceBuildLogs/Logs";
|
||||
import {
|
||||
Line,
|
||||
LogLine,
|
||||
logLineHeight,
|
||||
} from "components/WorkspaceBuildLogs/Logs";
|
||||
import { PortForwardButton } from "./PortForwardButton";
|
||||
import { VSCodeDesktopButton } from "components/Resources/VSCodeDesktopButton/VSCodeDesktopButton";
|
||||
import {
|
||||
@@ -25,15 +29,11 @@ import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||
import { colors } from "theme/colors";
|
||||
import { combineClasses } from "utils/combineClasses";
|
||||
import {
|
||||
LineWithID,
|
||||
workspaceAgentLogsMachine,
|
||||
} from "xServices/workspaceAgentLogs/workspaceAgentLogsXService";
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentMetadata,
|
||||
} from "../../api/typesGenerated";
|
||||
} from "api/typesGenerated";
|
||||
import { AppLink } from "./AppLink/AppLink";
|
||||
import { SSHButton } from "./SSHButton/SSHButton";
|
||||
import { Stack } from "../Stack/Stack";
|
||||
@@ -44,6 +44,14 @@ import { AgentVersion } from "./AgentVersion";
|
||||
import { AgentStatus } from "./AgentStatus";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
|
||||
// Logs are stored as the Line interface to make rendering
|
||||
// much more efficient. Instead of mapping objects each time, we're
|
||||
// able to just pass the array of logs to the component.
|
||||
export interface LineWithID extends Line {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface AgentRowProps {
|
||||
agent: WorkspaceAgent;
|
||||
@@ -68,24 +76,11 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
hideVSCodeDesktopButton,
|
||||
serverVersion,
|
||||
onUpdateAgent,
|
||||
storybookLogs,
|
||||
storybookAgentMetadata,
|
||||
sshPrefix,
|
||||
storybookLogs,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const [logsMachine, sendLogsEvent] = useMachine(workspaceAgentLogsMachine, {
|
||||
context: { agentID: agent.id },
|
||||
services: process.env.STORYBOOK
|
||||
? {
|
||||
getLogs: async () => {
|
||||
return storybookLogs || [];
|
||||
},
|
||||
streamLogs: () => async () => {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const theme = useTheme();
|
||||
const startupScriptAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [startupScriptOpen, setStartupScriptOpen] = useState(false);
|
||||
@@ -94,36 +89,20 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
showApps &&
|
||||
((agent.status === "connected" && hasAppsToDisplay) ||
|
||||
agent.status === "connecting");
|
||||
const hasStartupFeatures =
|
||||
Boolean(agent.logs_length) || Boolean(logsMachine.context.logs?.length);
|
||||
const hasStartupFeatures = Boolean(agent.logs_length);
|
||||
const { proxy } = useProxy();
|
||||
|
||||
const [showLogs, setShowLogs] = useState(
|
||||
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
|
||||
hasStartupFeatures,
|
||||
);
|
||||
useEffect(() => {
|
||||
setShowLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures);
|
||||
}, [agent.lifecycle_state, hasStartupFeatures]);
|
||||
// External applications can provide startup logs for an agent during it's spawn.
|
||||
// These could be Kubernetes logs, or other logs that are useful to the user.
|
||||
// For this reason, we want to fetch these logs when the agent is starting.
|
||||
useEffect(() => {
|
||||
if (agent.lifecycle_state === "starting") {
|
||||
sendLogsEvent("FETCH_LOGS");
|
||||
}
|
||||
}, [sendLogsEvent, agent.lifecycle_state]);
|
||||
useEffect(() => {
|
||||
// We only want to fetch logs when they are actually shown,
|
||||
// otherwise we can make a lot of requests that aren't necessary.
|
||||
if (showLogs && logsMachine.can("FETCH_LOGS")) {
|
||||
sendLogsEvent("FETCH_LOGS");
|
||||
}
|
||||
}, [logsMachine, sendLogsEvent, showLogs]);
|
||||
const agentLogs = useAgentLogs(agent.id, {
|
||||
enabled: showLogs,
|
||||
initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined,
|
||||
});
|
||||
const logListRef = useRef<List>(null);
|
||||
const logListDivRef = useRef<HTMLDivElement>(null);
|
||||
const startupLogs = useMemo(() => {
|
||||
const allLogs = logsMachine.context.logs || [];
|
||||
const allLogs = agentLogs || [];
|
||||
|
||||
const logs = [...allLogs];
|
||||
if (agent.logs_overflowed) {
|
||||
@@ -135,8 +114,13 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
});
|
||||
}
|
||||
return logs;
|
||||
}, [logsMachine.context.logs, agent.logs_overflowed]);
|
||||
}, [agentLogs, agent.logs_overflowed]);
|
||||
const [bottomOfLogs, setBottomOfLogs] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setShowLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures);
|
||||
}, [agent.lifecycle_state, hasStartupFeatures]);
|
||||
|
||||
// This is a layout effect to remove flicker when we're scrolling to the bottom.
|
||||
useLayoutEffect(() => {
|
||||
// If we're currently watching the bottom, we always want to stay at the bottom.
|
||||
@@ -396,6 +380,51 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const useAgentLogs = (
|
||||
agentId: string,
|
||||
{ enabled, initialData }: { enabled: boolean; initialData?: LineWithID[] },
|
||||
) => {
|
||||
const [logs, setLogs] = useState<LineWithID[] | undefined>(initialData);
|
||||
const socket = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
socket.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.current = API.watchWorkspaceAgentLogs(agentId, {
|
||||
// Get all logs
|
||||
after: 0,
|
||||
onMessage: (logs) => {
|
||||
setLogs((previousLogs) => {
|
||||
const newLogs: LineWithID[] = logs.map((log) => ({
|
||||
id: log.id,
|
||||
level: log.level || "info",
|
||||
output: log.output,
|
||||
time: log.created_at,
|
||||
}));
|
||||
|
||||
if (!previousLogs) {
|
||||
return newLogs;
|
||||
}
|
||||
|
||||
return [...previousLogs, ...newLogs];
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
displayError("Error on getting agent logs");
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.current?.close();
|
||||
};
|
||||
}, [agentId, enabled]);
|
||||
|
||||
return logs;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
agentRow: {
|
||||
backgroundColor: theme.palette.background.paperLight,
|
||||
|
||||
@@ -60,12 +60,25 @@ export const LogLine: FC<{
|
||||
const output = useMemo(() => {
|
||||
return convert.toHtml(line.output.split(/\r/g).pop() as string);
|
||||
}, [line.output]);
|
||||
const isUsingLineNumber = number !== undefined;
|
||||
|
||||
return (
|
||||
<div className={combineClasses([styles.line, line.level])} style={style}>
|
||||
<div
|
||||
className={combineClasses([
|
||||
styles.line,
|
||||
line.level,
|
||||
isUsingLineNumber && styles.lineNumber,
|
||||
])}
|
||||
style={style}
|
||||
>
|
||||
{!hideTimestamp && (
|
||||
<>
|
||||
<span className={styles.time}>
|
||||
<span
|
||||
className={combineClasses([
|
||||
styles.time,
|
||||
isUsingLineNumber && styles.number,
|
||||
])}
|
||||
>
|
||||
{number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</span>
|
||||
<span className={styles.space} />
|
||||
@@ -119,6 +132,9 @@ const useStyles = makeStyles((theme) => ({
|
||||
backgroundColor: theme.palette.warning.dark,
|
||||
},
|
||||
},
|
||||
lineNumber: {
|
||||
paddingLeft: theme.spacing(2),
|
||||
},
|
||||
space: {
|
||||
userSelect: "none",
|
||||
width: theme.spacing(3),
|
||||
@@ -132,4 +148,8 @@ const useStyles = makeStyles((theme) => ({
|
||||
display: "inline-block",
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
number: {
|
||||
width: theme.spacing(4),
|
||||
textAlign: "right",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -43,7 +43,7 @@ const renderWorkspacePage = async () => {
|
||||
jest
|
||||
.spyOn(api, "watchWorkspaceAgentLogs")
|
||||
.mockImplementation((_, options) => {
|
||||
options.onDone();
|
||||
options.onDone && options.onDone();
|
||||
return new WebSocket("");
|
||||
});
|
||||
renderWithAuth(<WorkspacePage />, {
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import * as API from "api/api";
|
||||
import { createMachine, assign } from "xstate";
|
||||
import { Line } from "components/WorkspaceBuildLogs/Logs";
|
||||
|
||||
// Logs are stored as the Line interface to make rendering
|
||||
// much more efficient. Instead of mapping objects each time, we're
|
||||
// able to just pass the array of logs to the component.
|
||||
export interface LineWithID extends Line {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const workspaceAgentLogsMachine = createMachine(
|
||||
{
|
||||
predictableActionArguments: true,
|
||||
id: "workspaceAgentLogsMachine",
|
||||
schema: {
|
||||
events: {} as
|
||||
| {
|
||||
type: "ADD_LOGS";
|
||||
logs: LineWithID[];
|
||||
}
|
||||
| {
|
||||
type: "FETCH_LOGS";
|
||||
}
|
||||
| {
|
||||
type: "DONE";
|
||||
},
|
||||
context: {} as {
|
||||
agentID: string;
|
||||
logs?: LineWithID[];
|
||||
},
|
||||
services: {} as {
|
||||
getLogs: {
|
||||
data: LineWithID[];
|
||||
};
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./workspaceAgentLogsXService.typegen").Typegen0,
|
||||
initial: "waiting",
|
||||
states: {
|
||||
waiting: {
|
||||
on: {
|
||||
FETCH_LOGS: "loading",
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "getLogs",
|
||||
onDone: {
|
||||
target: "watchLogs",
|
||||
actions: ["assignLogs"],
|
||||
},
|
||||
},
|
||||
},
|
||||
watchLogs: {
|
||||
id: "watchingLogs",
|
||||
invoke: {
|
||||
id: "streamLogs",
|
||||
src: "streamLogs",
|
||||
},
|
||||
on: {
|
||||
ADD_LOGS: {
|
||||
actions: "addLogs",
|
||||
},
|
||||
DONE: {
|
||||
target: "loaded",
|
||||
},
|
||||
},
|
||||
},
|
||||
loaded: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
getLogs: (ctx) =>
|
||||
API.getWorkspaceAgentLogs(ctx.agentID).then((data) =>
|
||||
data.map((log) => ({
|
||||
id: log.id,
|
||||
level: log.level || "info",
|
||||
output: log.output,
|
||||
time: log.created_at,
|
||||
})),
|
||||
),
|
||||
streamLogs: (ctx) => async (callback) => {
|
||||
let after = 0;
|
||||
if (ctx.logs && ctx.logs.length > 0) {
|
||||
after = ctx.logs[ctx.logs.length - 1].id;
|
||||
}
|
||||
|
||||
const socket = API.watchWorkspaceAgentLogs(ctx.agentID, {
|
||||
after,
|
||||
onMessage: (logs) => {
|
||||
callback({
|
||||
type: "ADD_LOGS",
|
||||
logs: logs.map((log) => ({
|
||||
id: log.id,
|
||||
level: log.level || "info",
|
||||
output: log.output,
|
||||
time: log.created_at,
|
||||
})),
|
||||
});
|
||||
},
|
||||
onDone: () => {
|
||||
callback({ type: "DONE" });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignLogs: assign({
|
||||
logs: (_, { data }) => data,
|
||||
}),
|
||||
addLogs: assign({
|
||||
logs: (context, event) => {
|
||||
const previousLogs = context.logs ?? [];
|
||||
return [...previousLogs, ...event.logs];
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user