feat(web): live SSE log tail and log retention setting
Server Deploy / deploy (push) Successful in 1m35s

This commit is contained in:
2026-07-20 15:18:36 +01:00
parent 63dadf6239
commit 39348c9491
3 changed files with 112 additions and 23 deletions
+33 -2
View File
@@ -168,6 +168,9 @@ export default function SettingsPage() {
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
const [useTLS, setUseTLS] = useState(false);
// Workflow log retention (days). 0 = keep forever.
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [saved, setSaved] = useState(false);
useEffect(() => {
@@ -183,11 +186,15 @@ export default function SettingsPage() {
setFromAddr(settings.email?.from_addr ?? "");
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
setUseTLS(settings.email?.use_tls ?? false);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: { alerts: AlertSettings; email: EmailSettings }) =>
api.saveSettings(payload),
mutationFn: (payload: {
alerts: AlertSettings;
email: EmailSettings;
workflow_log_retention_days?: number | null;
}) => api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
@@ -218,6 +225,7 @@ export default function SettingsPage() {
to_addrs: toList,
use_tls: useTLS,
},
workflow_log_retention_days: logRetentionDays,
});
}
@@ -374,6 +382,29 @@ export default function SettingsPage() {
</div>
</Card>
{/* Workflow logs */}
<Card>
<CardHeader>
<CardTitle>Workflow Logs</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
How long to keep workflow run logs on the server before they are
automatically deleted.
</p>
<Field
label="Log retention (days)"
hint="0 = keep forever. Applies to per-run step output logs."
>
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => setLogRetentionDays(Number(e.target.value))}
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</Field>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
+60 -18
View File
@@ -1,6 +1,7 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ServerRun, StepRun } from "@/lib/api";
import { Button, Badge, Card } from "@/components/ui";
@@ -20,6 +21,50 @@ function StatusBadge({ status }: { status: string }) {
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
}
function ServerLog({
runId,
serverId,
status,
}: {
runId: string;
serverId: string;
status: string;
}) {
const [text, setText] = useState("");
const preRef = useRef<HTMLPreElement>(null);
const running = status === "running";
useEffect(() => {
if (running) {
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), {
withCredentials: true,
});
es.onmessage = (e) => setText((t) => t + e.data + "\n");
es.addEventListener("done", () => es.close());
es.onerror = () => es.close();
return () => es.close();
}
// terminal: fetch the whole file once
api
.getServerRunLog(runId, serverId)
.then(setText)
.catch(() => setText(""));
}, [running, runId, serverId]);
useEffect(() => {
preRef.current?.scrollTo(0, preRef.current.scrollHeight);
}, [text]);
return (
<pre
ref={preRef}
className="mt-3 max-h-80 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 font-mono text-xs text-text-secondary"
>
{text || (running ? "Waiting for output…" : "No output.")}
</pre>
);
}
export default function RunDetail() {
const { runId } = useParams<{ runId: string }>();
const queryClient = useQueryClient();
@@ -65,32 +110,29 @@ export default function RunDetail() {
</div>
<div className="space-y-2">
{sr.steps.map((st: StepRun) => (
<details
<div
key={st.order}
className="rounded-lg border border-border bg-surface-2 p-2"
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-surface-2 p-2"
>
<summary className="flex cursor-pointer items-center justify-between gap-2">
<span className="text-sm text-text-primary">{st.name}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-text-secondary">
attempts: {st.attempts}
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
</span>
<StatusBadge status={st.status} />
<span className="text-sm text-text-primary">{st.name}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-text-secondary">
attempts: {st.attempts}
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
</span>
</summary>
{(st.stdout || st.stderr) && (
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary">
{st.stdout}
{st.stderr ? `\n${st.stderr}` : ""}
</pre>
)}
</details>
<StatusBadge status={st.status} />
</span>
</div>
))}
{sr.steps.length === 0 && (
<p className="text-xs text-text-secondary">No steps yet.</p>
)}
</div>
<ServerLog
runId={run.run_id}
serverId={sr.server_id}
status={sr.status}
/>
</Card>
))}
{run.server_runs.length === 0 && (