This commit is contained in:
RGJorge
2026-05-04 02:43:11 +00:00
parent 36080e466b
commit d18a459a57
6 changed files with 178 additions and 112 deletions
+12 -10
View File
@@ -75,7 +75,7 @@ function Dashboard({ token }: { token: string }) {
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => { const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
savedPositions.current = pos; savedPositions.current = pos;
}, []); }, []);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = useDocker(token, statsStore, onPositions); const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince } = useDocker(token, statsStore, onPositions);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const initialLayoutDone = useRef(false); const initialLayoutDone = useRef(false);
@@ -647,20 +647,20 @@ function Dashboard({ token }: { token: string }) {
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
onAction={(action) => { onAction={(action) => {
const svc = contextMenu.service; const svc = contextMenu.service;
// Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
svc.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0;
setProcessing(svc.uid, expectedState, minDuration);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers }) fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
.then((r) => { .then((r) => {
if (r.ok) { if (!r.ok) clearProcessing(svc.uid);
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
svc.state;
const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0;
setProcessing(svc.uid, expectedState, minDuration);
}
}) })
.catch(() => {}); .catch(() => clearProcessing(svc.uid));
}} }}
onOpenLogs={() => { onOpenLogs={() => {
const svc = contextMenu.service; const svc = contextMenu.service;
@@ -703,6 +703,7 @@ function Dashboard({ token }: { token: string }) {
closing={panelClosing} closing={panelClosing}
onClose={closeDetail} onClose={closeDetail}
onAction={setProcessing} onAction={setProcessing}
clearProcessing={clearProcessing}
sendMessage={sendMessage} sendMessage={sendMessage}
clearLogLines={clearLogLines} clearLogLines={clearLogLines}
connections={filteredConnections} connections={filteredConnections}
@@ -711,6 +712,7 @@ function Dashboard({ token }: { token: string }) {
initialLogsFullscreen={openLogsFullscreen} initialLogsFullscreen={openLogsFullscreen}
envFiles={envFiles} envFiles={envFiles}
onEnvFileChange={handleEnvFileChange} onEnvFileChange={handleEnvFileChange}
events={events}
/> />
)} )}
</div> </div>
+55 -32
View File
@@ -19,10 +19,39 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const [logLines, setLogLines] = useState<LogLine[]>([]); const [logLines, setLogLines] = useState<LogLine[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing } // Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map()); const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const lastRawServicesRef = useRef<Service[]>([]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined); const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Apply processing overlay to raw services data
const applyProcessing = useCallback((raw: Service[]): Service[] => {
const processing = processingRef.current;
if (processing.size === 0) return raw;
const now = Date.now();
return raw.map((s: Service) => {
const entry = processing.get(s.uid);
if (!entry) return s;
const elapsed = now - entry.startedAt;
if (elapsed < entry.minDuration) {
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
}
if (s.state === entry.expected) {
processing.delete(s.uid);
return s;
}
if (s.state === "crashed" && entry.expected === "running") {
processing.delete(s.uid);
return s;
}
if (now - entry.startedAt > 15000) {
processing.delete(s.uid);
return s;
}
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
});
}, []);
// Single init call: services + connections + positions // Single init call: services + connections + positions
useEffect(() => { useEffect(() => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -80,37 +109,8 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
switch (msg.type as WSMessage["type"]) { switch (msg.type as WSMessage["type"]) {
case "services": { case "services": {
const processing = processingRef.current; lastRawServicesRef.current = msg.data as Service[];
let incoming = msg.data as Service[]; const incoming = applyProcessing(msg.data as Service[]);
if (processing.size > 0) {
const now = Date.now();
incoming = incoming.map((s: Service) => {
const entry = processing.get(s.uid);
if (!entry) return s;
// Don't clear processing until minDuration has passed (restart/rebuild need time for stop→start cycle)
const elapsed = now - entry.startedAt;
if (elapsed < entry.minDuration) {
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
}
// Server confirms expected state → clear processing
if (s.state === entry.expected) {
processing.delete(s.uid);
return s;
}
// Container crashed while we expected "running" → clear processing, show crashed
if (s.state === "crashed" && entry.expected === "running") {
processing.delete(s.uid);
return s;
}
// Timeout after 15s → give up, show real state
if (now - entry.startedAt > 15000) {
processing.delete(s.uid);
return s;
}
// State doesn't match expected → keep in processing, ignore stale data
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
});
}
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming); setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
break; break;
} }
@@ -142,6 +142,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
return next.length > 2000 ? next.slice(-1500) : next; return next.length > 2000 ? next.slice(-1500) : next;
}); });
break; break;
case "action_error": {
processingRef.current.delete(msg.data.uid);
setServices((prev) => [...prev]);
break;
}
} }
} catch (err) { } catch (err) {
console.error("Failed to parse WS message:", err); console.error("Failed to parse WS message:", err);
@@ -191,11 +196,29 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration }); processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000)); actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s)); setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s));
// Re-evaluate every 1s after minDuration until processing clears.
// Handles the case where the server stops broadcasting (hash unchanged).
if (minDuration > 0) {
const interval = setInterval(() => {
if (!processingRef.current.has(uid)) { clearInterval(interval); return; }
const elapsed = Date.now() - startedAt;
if (elapsed < minDuration) return;
const incoming = applyProcessing(lastRawServicesRef.current);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
// applyProcessing deletes the entry when resolved or timed out (15s)
if (!processingRef.current.has(uid)) clearInterval(interval);
}, 1000);
}
}, [applyProcessing]);
const clearProcessing = useCallback((uid: string) => {
processingRef.current.delete(uid);
setServices((prev) => [...prev]);
}, []); }, []);
const getLogsSince = useCallback((uid: string): number | undefined => { const getLogsSince = useCallback((uid: string): number | undefined => {
return actionTimestamps.current.get(uid); return actionTimestamps.current.get(uid);
}, []); }, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince }; return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince };
} }
+36 -17
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react";
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react";
import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types"; import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent } from "../../shared/types";
type Tab = "info" | "config" | "env" | "stats"; type Tab = "info" | "config" | "env" | "stats";
@@ -36,6 +36,7 @@ interface DetailPanelProps {
closing?: boolean; closing?: boolean;
onClose: () => void; onClose: () => void;
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void; onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
clearProcessing: (uid: string) => void;
sendMessage: (msg: WSMessage) => void; sendMessage: (msg: WSMessage) => void;
clearLogLines: () => void; clearLogLines: () => void;
connections: Connection[]; connections: Connection[];
@@ -44,9 +45,10 @@ interface DetailPanelProps {
initialLogsFullscreen?: boolean; initialLogsFullscreen?: boolean;
envFiles: Record<string, string>; envFiles: Record<string, string>;
onEnvFileChange: (composeFile: string, envFile: string | null) => void; onEnvFileChange: (composeFile: string, envFile: string | null) => void;
events: DockerEvent[];
} }
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange }: DetailPanelProps) { export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) {
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]); const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -101,6 +103,17 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
setActionResult(null); setActionResult(null);
setConfirmAction(null); setConfirmAction(null);
actionTimestampRef.current = Math.floor(Date.now() / 1000); actionTimestampRef.current = Math.floor(Date.now() / 1000);
// Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
service.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0;
onAction(service.uid, expectedState, minDuration);
setInitialLogs([]);
clearLogLines();
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -108,29 +121,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setActionResult({ type: "success", message: `${action} successful` }); setActionResult({ type: "success", message: `${action} successful` });
setInitialLogs([]);
clearLogLines();
// Set processing — pass expected state so we wait for server to confirm
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
service.state;
// restart/rebuild go through stop→start cycle, need minDuration to avoid clearing on intermediate states
const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0;
onAction(service.uid, expectedState, minDuration);
if (action === "remove") { if (action === "remove") {
setTimeout(() => handleClose(), 1000); setTimeout(() => handleClose(), 1000);
} }
} else { } else {
clearProcessing(service.uid);
setActionResult({ type: "error", message: data.error || `Failed to ${action}` }); setActionResult({ type: "error", message: data.error || `Failed to ${action}` });
} }
} catch { } catch {
clearProcessing(service.uid);
setActionResult({ type: "error", message: `Failed to ${action}` }); setActionResult({ type: "error", message: `Failed to ${action}` });
} finally { } finally {
setActionLoading(null); setActionLoading(null);
setTimeout(() => setActionResult(null), 3000); setTimeout(() => setActionResult(null), 3000);
} }
}, [service.id, service.uid, token, onAction]); }, [service.id, service.uid, token, onAction, clearProcessing]);
const runExec = useCallback(async () => { const runExec = useCallback(async () => {
if (!execCmd.trim()) return; if (!execCmd.trim()) return;
@@ -254,10 +259,23 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
setAutoScroll((prev) => prev === atBottom ? prev : atBottom); setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
}, []); }, []);
const allLines = useMemo( // Docker events as special log lines
() => [...initialLogs, ...logLines], const eventLogLines = useMemo(() => {
[initialLogs, logLines] return events
); .filter((e) => e.service === service.uid)
.map((e): LogLine => ({
container: service.id,
line: `[DOCKER] Container ${e.action}`,
timestamp: new Date(e.time * 1000).toISOString(),
stream: "stderr",
}));
}, [events, service.uid, service.id]);
const allLines = useMemo(() => {
const combined = [...initialLogs, ...logLines, ...eventLogLines];
combined.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || ""));
return combined;
}, [initialLogs, logLines, eventLogLines]);
const connectedSvcs = useMemo(() => { const connectedSvcs = useMemo(() => {
const connectedUids = new Set<string>(); const connectedUids = new Set<string>();
@@ -1039,6 +1057,7 @@ const ERROR_PATTERN = /\b(error|fatal|critical|exception|traceback|panic|failed|
const WARN_PATTERN = /\b(warn|warning)\b/i; const WARN_PATTERN = /\b(warn|warning)\b/i;
function logLineColor(l: LogLine): string { function logLineColor(l: LogLine): string {
if (l.line.startsWith("[DOCKER]")) return "text-cyan-400 font-semibold";
if (ERROR_PATTERN.test(l.line)) return "text-red-400"; if (ERROR_PATTERN.test(l.line)) return "text-red-400";
if (WARN_PATTERN.test(l.line)) return "text-yellow-400"; if (WARN_PATTERN.test(l.line)) return "text-yellow-400";
return "text-slate-400"; return "text-slate-400";
+61 -43
View File
@@ -245,62 +245,80 @@ export function streamContainerLogs(
const container = docker.getContainer(id); const container = docker.getContainer(id);
let stream: NodeJS.ReadableStream | null = null; let stream: NodeJS.ReadableStream | null = null;
let destroyed = false; let destroyed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const destroyStream = (s: unknown) => { const destroyStream = (s: unknown) => {
if (s && typeof (s as any).destroy === "function") (s as any).destroy(); if (s && typeof (s as any).destroy === "function") (s as any).destroy();
}; };
container.logs({ function connect() {
stdout: true, if (destroyed) return;
stderr: true,
follow: true,
since: Math.floor(Date.now() / 1000),
timestamps: true,
}).then((s) => {
stream = s as unknown as NodeJS.ReadableStream;
if (destroyed) { container.logs({
destroyStream(stream); stdout: true,
stream = null; stderr: true,
return; follow: true,
} since: Math.floor(Date.now() / 1000),
timestamps: true,
}).then((s) => {
stream = s as unknown as NodeJS.ReadableStream;
// Docker multiplexed stream parsing for follow mode if (destroyed) {
let buffer = Buffer.alloc(0); destroyStream(stream);
stream = null;
stream.on("data", (chunk: Buffer) => { return;
if (destroyed) return;
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 8) {
const streamType = buffer[0];
const size = buffer.readUInt32BE(4);
if (buffer.length < 8 + size) break;
const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd();
buffer = buffer.slice(8 + size);
if (!payload) continue;
const spaceIdx = payload.indexOf(" ");
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
onLine({
container: id,
line,
timestamp,
stream: streamType === 2 ? "stderr" : "stdout",
});
} }
// Docker multiplexed stream parsing for follow mode
let buffer = Buffer.alloc(0);
stream.on("data", (chunk: Buffer) => {
if (destroyed) return;
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 8) {
const streamType = buffer[0];
const size = buffer.readUInt32BE(4);
if (buffer.length < 8 + size) break;
const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd();
buffer = buffer.slice(8 + size);
if (!payload) continue;
const spaceIdx = payload.indexOf(" ");
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
onLine({
container: id,
line,
timestamp,
stream: streamType === 2 ? "stderr" : "stdout",
});
}
});
// Auto-reconnect when stream closes (container stop/restart)
stream.on("end", () => {
stream = null;
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
});
stream.on("error", () => {
stream = null;
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
});
}).catch(() => {
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
}); });
}).catch((err) => { }
console.error(`Failed to stream logs for ${id}:`, err);
}); connect();
return { return {
destroy() { destroy() {
destroyed = true; destroyed = true;
clearTimeout(reconnectTimer);
if (stream) { if (stream) {
destroyStream(stream); destroyStream(stream);
stream = null; stream = null;
+12 -9
View File
@@ -167,19 +167,26 @@ app.post("/api/containers/:id/rebuild", async (c) => {
const info = await container.inspect(); const info = await container.inspect();
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
if (!composeFile || !serviceName) { if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400); return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
} }
const uid = `${project}/${serviceName}`;
const envArgs = findEnvFileArgs(composeFile); const envArgs = findEnvFileArgs(composeFile);
// Run rebuild in background — respond immediately
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--build", "-d", serviceName], { const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--build", "-d", serviceName], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
const exitCode = await proc.exited; proc.exited.then(async (exitCode) => {
if (exitCode !== 0) { if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text(); const stderr = await new Response(proc.stderr).text();
return c.json({ error: stderr || `Rebuild failed with exit code ${exitCode}` }, 500); broadcast({ type: "action_error", data: { uid, action: "rebuild", error: stderr || `Rebuild failed with exit code ${exitCode}` } });
} }
scheduleRefresh();
}).catch((err) => {
broadcast({ type: "action_error", data: { uid, action: "rebuild", error: err?.message || "Rebuild failed" } });
});
return c.json({ ok: true }); return c.json({ ok: true });
} catch (err: any) { } catch (err: any) {
return c.json({ error: err?.message || "Failed to rebuild container" }, 500); return c.json({ error: err?.message || "Failed to rebuild container" }, 500);
@@ -446,18 +453,14 @@ async function refreshStats(services: Service[]) {
// Debounced refresh for Docker events // Debounced refresh for Docker events
let refreshTimer: ReturnType<typeof setTimeout> | undefined; let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined; let retryTimer: ReturnType<typeof setTimeout> | undefined;
let lateRetryTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh() { function scheduleRefresh() {
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update) // Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
lastServicesHash = ""; lastServicesHash = "";
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
clearTimeout(retryTimer); clearTimeout(retryTimer);
clearTimeout(lateRetryTimer);
refreshTimer = setTimeout(() => { refreshTimer = setTimeout(() => {
refreshServices(); refreshServices();
retryTimer = setTimeout(refreshServices, 1500); retryTimer = setTimeout(refreshServices, 1500);
// Late retry for restart/rebuild: client ignores first 5s, so re-broadcast after that
lateRetryTimer = setTimeout(() => { lastServicesHash = ""; refreshServices(); }, 6000);
}, 500); }, 500);
} }
+2 -1
View File
@@ -57,4 +57,5 @@ export type WSMessage =
| { type: "docker_event"; data: DockerEvent } | { type: "docker_event"; data: DockerEvent }
| { type: "subscribe_logs"; container: string } | { type: "subscribe_logs"; container: string }
| { type: "unsubscribe_logs" } | { type: "unsubscribe_logs" }
| { type: "log_line"; data: LogLine }; | { type: "log_line"; data: LogLine }
| { type: "action_error"; data: { uid: string; action: string; error: string } };