Skip to content

Commit bc0bd3f

Browse files
Log viewer improvements (#24)
* Log viewer improvements * Close EventSource after receiving all logs in places where no new logs will ever be received
1 parent bc79fee commit bc0bd3f

10 files changed

Lines changed: 199 additions & 83 deletions

File tree

backend/src/db/repo/app.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
33
import {
44
DeploymentSource,
55
type DeploymentStatus,
6+
type LogType,
67
type PermissionLevel,
78
type WebhookEvent,
89
} from "../../generated/prisma/enums.ts";
@@ -16,6 +17,7 @@ import type {
1617
AppCreate,
1718
Deployment,
1819
DeploymentConfig,
20+
Log,
1921
} from "../models.ts";
2022
import { DeploymentRepo } from "./deployment.ts";
2123

@@ -215,6 +217,30 @@ export class AppRepo {
215217
return app.config.deployment;
216218
}
217219

220+
async getLogs(
221+
appId: number,
222+
deploymentId: number | null,
223+
cursor: number,
224+
type: LogType,
225+
limit: number,
226+
): Promise<Log[]> {
227+
// Fetch them in reverse order so that we can take only the 500 most recent lines
228+
return (
229+
await this.client.log.findMany({
230+
where: {
231+
id: { gt: cursor },
232+
type: type,
233+
deployment: {
234+
appId,
235+
...(deploymentId !== null ? { id: deploymentId } : {}),
236+
},
237+
},
238+
orderBy: [{ timestamp: "desc" }, { index: "desc" }],
239+
take: limit,
240+
})
241+
).reverse();
242+
}
243+
218244
async getDeploymentConfig(appId: number): Promise<DeploymentConfig> {
219245
const app = await this.client.app.findUnique({
220246
where: { id: appId },

backend/src/db/repo/deployment.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { randomBytes } from "node:crypto";
22
import type {
33
AppType,
44
DeploymentStatus,
5-
LogType,
65
PermissionLevel,
76
} from "../../generated/prisma/enums.ts";
87
import type {
@@ -311,26 +310,6 @@ export class DeploymentRepo {
311310
return count === 1;
312311
}
313312

314-
async getLogs(
315-
deploymentId: number,
316-
cursor: number,
317-
type: LogType,
318-
limit: number,
319-
): Promise<Log[]> {
320-
// Fetch them in reverse order so that we can take only the 500 most recent lines
321-
return (
322-
await this.client.log.findMany({
323-
where: {
324-
id: { gt: cursor },
325-
deploymentId: deploymentId,
326-
type: type,
327-
},
328-
orderBy: [{ timestamp: "desc" }, { index: "desc" }],
329-
take: limit,
330-
})
331-
).reverse();
332-
}
333-
334313
async insertLogs(logs: Omit<Log, "id">[]) {
335314
await this.client.log.createMany({
336315
data: logs,
@@ -341,14 +320,35 @@ export class DeploymentRepo {
341320
deploymentIds.add(log.deploymentId);
342321
}
343322

344-
await Promise.all(
345-
[...deploymentIds].map((deploymentId) => {
323+
const deploymentIdArray = [...deploymentIds];
324+
325+
const deploymentMessages = Promise.all(
326+
deploymentIdArray.map((deploymentId) => {
346327
if (typeof deploymentId !== "number") {
347328
return Promise.resolve();
348329
}
349330
return this.publish(`deployment_${deploymentId}_logs`, "");
350331
}),
351332
);
333+
334+
const appMessages = this.client.app
335+
.findMany({
336+
where: { deployments: { some: { id: { in: deploymentIdArray } } } },
337+
select: { id: true },
338+
})
339+
.then((ids) => [...new Set(ids.map((it) => it.id))])
340+
.then((appIds) =>
341+
Promise.all(
342+
[...new Set(appIds)].map((appId) => {
343+
if (typeof appId !== "number") {
344+
return Promise.resolve();
345+
}
346+
return this.publish(`app_${appId}_logs`, "");
347+
}),
348+
),
349+
);
350+
351+
await Promise.all([deploymentMessages, appMessages]);
352352
}
353353

354354
async unlinkRepositoryFromAllDeployments(repoId: number) {

backend/src/handlers/getAppLogs.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,9 @@ export const getAppLogsHandler: HandlerMap["getAppLogs"] = async (
4040
// Just start at the beginning
4141
}
4242
}
43-
4443
await getAppLogs(
4544
ctx.request.params.appId,
46-
ctx.request.params.deploymentId,
45+
ctx.request.query.deploymentId ?? null,
4746
req.user.id,
4847
ctx.request.query.type,
4948
lastLogId,

backend/src/service/getAppLogs.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const k8sConcurrentViewers = meter.createUpDownCounter(
2828

2929
export async function getAppLogs(
3030
appId: number,
31-
deploymentId: number,
31+
deploymentId: number | null,
3232
userId: number,
3333
type: LogType,
3434
lastLogId: number,
@@ -44,7 +44,7 @@ export async function getAppLogs(
4444
}
4545

4646
// Pull logs from Postgres and send them to the client as they come in
47-
if (typeof deploymentId !== "number") {
47+
if (typeof deploymentId !== "number" && deploymentId !== null) {
4848
// Extra sanity check due to potential SQL injection below in `subscribe`; should never happen because of openapi-backend's request validation and additional sanitization in `subscribe()`
4949
throw new Error("deploymentId must be a number.");
5050
}
@@ -56,7 +56,8 @@ export async function getAppLogs(
5656

5757
if (collectLogs || type === "BUILD") {
5858
const fetchNewLogs = async () => {
59-
const newLogs = await db.deployment.getLogs(
59+
const newLogs = await db.app.getLogs(
60+
app.id,
6061
deploymentId,
6162
lastLogId,
6263
type,
@@ -80,9 +81,14 @@ export async function getAppLogs(
8081
);
8182
};
8283

84+
const channel =
85+
deploymentId === null
86+
? `app_${appId}_logs`
87+
: `deployment_${deploymentId}_logs`;
88+
8389
// When new logs come in, send them to the client
8490
const unsubscribe = await db.subscribe(
85-
`deployment_${deploymentId}_logs`,
91+
channel,
8692
() =>
8793
void fetchNewLogs().catch((err) =>
8894
logger.error(err, "Failed to fetch new logs"),
@@ -106,6 +112,11 @@ export async function getAppLogs(
106112
);
107113
}
108114

115+
if (!deploymentId) {
116+
const recentDeployment = await db.app.getMostRecentDeployment(appId);
117+
deploymentId = recentDeployment.id;
118+
}
119+
109120
const { CoreV1Api: core, Log: log } = await getClientsForRequest(
110121
userId,
111122
app.projectId,

frontend/src/components/Logs.tsx

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@ import type { components, paths } from "@/generated/openapi";
22
import { useEventSource } from "@/hooks/useEventSource";
33
import clsx from "clsx";
44
import { AlertTriangle, FileClock, Loader, SatelliteDish } from "lucide-react";
5-
import { useLayoutEffect, useRef, useState } from "react";
5+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
66
import { Button } from "./ui/button";
77

88
type Deployment =
99
paths["/app/{appId}/deployments/{deploymentId}"]["get"]["responses"]["200"]["content"]["application/json"];
1010

1111
type LogType = components["schemas"]["LogLine"]["type"];
1212

13-
export const Logs = ({
14-
deployment,
15-
type,
16-
}: {
17-
deployment: Pick<
13+
type LogsProps = {
14+
deployment?: Pick<
1815
Deployment,
1916
"status" | "id" | "appId" | "updatedAt" | "podStatus"
2017
>;
2118
type: LogType;
22-
}) => {
19+
appId?: number;
20+
follow: boolean;
21+
};
22+
23+
export const Logs = ({ deployment, type, appId, follow }: LogsProps) => {
2324
const [logs, setLogs] = useState<components["schemas"]["LogLine"][]>([]);
2425
const [noLogs, setNoLogs] = useState(false); // Set to true when we know there are no logs for this deployment
26+
const [done, setDone] = useState(false); // Set to true when all past logs have been sent and the viewer is up-to-date
2527

2628
const logsBody = useRef<HTMLDivElement | null>(null);
2729
const lastScroll = useRef({ scrollTop: 0, hasScrolledUp: false });
@@ -39,13 +41,17 @@ export const Logs = ({
3941
}
4042
}, [logs]);
4143

42-
const { connecting, connected } = useEventSource(
43-
new URL(
44-
`${window.location.protocol}//${window.location.host}/api/app/${deployment.appId}/deployments/${deployment.id}/logs?type=${type}`,
45-
),
44+
const url =
45+
deployment === undefined
46+
? `${window.location.protocol}//${window.location.host}/api/app/${appId}/logs?type=${type}`
47+
: `${window.location.protocol}//${window.location.host}/api/app/${appId}/logs?type=${type}&deploymentId=${deployment.id}`;
48+
49+
const { connecting, connected, close, reconnect } = useEventSource(
50+
new URL(url),
4651
["log", "pastLogsSent"],
4752
(eventName, event) => {
4853
if (eventName === "pastLogsSent") {
54+
setDone(true);
4955
if (logs.length === 0) {
5056
setNoLogs(true);
5157
}
@@ -69,25 +75,39 @@ export const Logs = ({
6975
},
7076
);
7177

78+
useEffect(() => {
79+
if (!follow && done && connected) {
80+
close();
81+
}
82+
83+
if (follow && !connected && !connecting) {
84+
reconnect();
85+
}
86+
}, [done, follow, connected, connecting, close, reconnect]);
87+
7288
return (
7389
<>
74-
{connecting ? (
75-
<p className="flex items-center gap-2 font-mono text-sm">
76-
<Loader className="animate-spin" /> Connecting...
77-
</p>
78-
) : !connected ? (
79-
<p className="mb-2 flex items-center gap-2 font-mono text-sm text-amber-600">
80-
<AlertTriangle /> Disconnected. New logs will not appear until the
81-
connection is re-established.
82-
</p>
83-
) : (
84-
<p className="mb-2 flex items-center gap-2 font-mono text-sm text-blue-500">
85-
<div className="relative h-5 w-4">
86-
<div className="absolute top-1/2 left-1/2 size-2 -translate-1/2 animate-pulse rounded-full bg-blue-500" />
87-
<div className="absolute top-1/2 left-1/2 size-2 -translate-1/2 animate-ping rounded-full bg-blue-500" />
88-
</div>
89-
Receiving logs in realtime
90-
</p>
90+
{follow && (
91+
<>
92+
{connecting ? (
93+
<p className="flex items-center gap-2 font-mono text-sm">
94+
<Loader className="animate-spin" /> Connecting...
95+
</p>
96+
) : !connected ? (
97+
<p className="mb-2 flex items-center gap-2 font-mono text-sm text-amber-600">
98+
<AlertTriangle /> Disconnected. New logs will not appear until the
99+
connection is re-established.
100+
</p>
101+
) : (
102+
<p className="mb-2 flex items-center gap-2 font-mono text-sm text-blue-500">
103+
<div className="relative h-5 w-4">
104+
<div className="absolute top-1/2 left-1/2 size-2 -translate-1/2 animate-pulse rounded-full bg-blue-500" />
105+
<div className="absolute top-1/2 left-1/2 size-2 -translate-1/2 animate-ping rounded-full bg-blue-500" />
106+
</div>
107+
Receiving logs in realtime
108+
</p>
109+
)}
110+
</>
91111
)}
92112

93113
<div
@@ -120,7 +140,8 @@ export const Logs = ({
120140
{/* "w-0" above forces this column to take up as little horizontal space as possible */}
121141
{new Date(log.time).toLocaleString()}
122142
</td>
123-
{(deployment.podStatus?.total ?? 1) > 1 && (
143+
{(deployment === undefined ||
144+
(deployment.podStatus?.total ?? 1) > 1) && (
124145
<td className="px-2">
125146
<span className="opacity-70">{log.pod}</span>
126147
</td>
@@ -138,7 +159,8 @@ export const Logs = ({
138159
<SatelliteDish /> Logs Unavailable
139160
</p>
140161
<p className="ml-8 opacity-50">
141-
{["PENDING", "BUILDING"].includes(deployment.status)
162+
{deployment !== undefined &&
163+
["PENDING", "BUILDING"].includes(deployment.status)
142164
? "Waiting for the build to start."
143165
: null}
144166
</p>
@@ -149,9 +171,11 @@ export const Logs = ({
149171
<FileClock /> No Logs Found
150172
</p>
151173
<p className="ml-8 opacity-50">
152-
{["PENDING", "BUILDING", "DEPLOYING"].includes(deployment.status)
174+
{deployment !== undefined &&
175+
["PENDING", "BUILDING", "DEPLOYING"].includes(deployment.status)
153176
? "Waiting for your app to be deployed."
154-
: ["COMPLETE", "STOPPED"].includes(deployment.status) &&
177+
: deployment !== undefined &&
178+
["COMPLETE", "STOPPED"].includes(deployment.status) &&
155179
type === "BUILD"
156180
? "Build completed with no log output."
157181
: "Logs from your app will appear here."}

frontend/src/hooks/useEventSource.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useEffectEvent, useRef, useState } from "react";
22

33
export const useEventSource = <T extends string>(
44
url: URL,
@@ -7,6 +7,8 @@ export const useEventSource = <T extends string>(
77
) => {
88
const [hasConnected, setHasConnected] = useState(false); // Whether a connection has been established before; true after the first connection is successfully opened
99
const [connected, setConnected] = useState(false);
10+
const [reconnectCounter, setReconnectCounter] = useState(0);
11+
const source = useRef<EventSource | null>(null);
1012
const shouldOpen = useRef(true);
1113

1214
const setupEventSource = (
@@ -25,9 +27,11 @@ export const useEventSource = <T extends string>(
2527
setTimeout(() => {
2628
if (!shouldOpen.current) return; // The component has unmounted; we shouldn't try to reconnect anymore
2729
reconnectCallback(setupEventSource(reconnectCallback));
28-
}, 3000);
30+
}, 500);
2931
};
3032

33+
source.current = eventSource;
34+
3135
for (const eventName of eventNames) {
3236
eventSource.addEventListener(eventName, (event: MessageEvent) =>
3337
onMessage(eventName, event),
@@ -37,18 +41,31 @@ export const useEventSource = <T extends string>(
3741
return eventSource;
3842
};
3943

44+
const setup = useEffectEvent(setupEventSource);
45+
const urlString = url.toString(); // Equal URLs don't have Object.is() equality, so the useEffect would be triggered on every render if we didn't convert this into a string first.
46+
4047
useEffect(() => {
4148
shouldOpen.current = true;
4249
let eventSource: EventSource;
43-
eventSource = setupEventSource((newEventSource) => {
50+
eventSource = setup((newEventSource) => {
4451
eventSource = newEventSource;
4552
});
4653

4754
return () => {
4855
shouldOpen.current = false;
4956
eventSource.close();
5057
};
51-
}, [url.toString()]);
58+
}, [urlString, reconnectCounter]);
5259

53-
return { connected, connecting: !connected && !hasConnected };
60+
return {
61+
connected,
62+
connecting: !connected && !hasConnected,
63+
close: () => {
64+
source.current?.close();
65+
setConnected(false);
66+
},
67+
reconnect: () => {
68+
setReconnectCounter((current) => current + 1);
69+
},
70+
};
5471
};

0 commit comments

Comments
 (0)