chore(site): replace agent log service (#9814)

This commit is contained in:
Bruno Quaresma
2023-09-22 16:13:25 -03:00
committed by GitHub
parent 7bb0061804
commit 85ab9c2d48
6 changed files with 114 additions and 192 deletions
+2 -2
View File
@@ -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',
},
+73 -44
View File
@@ -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];
},
}),
},
},
);