mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.21
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyProcessing, arraysEqual, type ProcessingEntry } from "./processing";
|
||||
import type { Service } from "../../shared/types";
|
||||
|
||||
function makeSvc(overrides: Partial<Service> = {}): Service {
|
||||
return {
|
||||
id: "abc123",
|
||||
uid: "proj/svc",
|
||||
name: "svc",
|
||||
image: "node:20",
|
||||
state: "running",
|
||||
status: "Up 5 minutes",
|
||||
ports: [],
|
||||
networks: ["default"],
|
||||
network_ips: {},
|
||||
project: "proj",
|
||||
compose_file: "",
|
||||
env: [],
|
||||
restart_policy: "",
|
||||
memory_limit: 0,
|
||||
cpu_quota: 0,
|
||||
health_status: "",
|
||||
health_log: [],
|
||||
exit_code: 0,
|
||||
restart_count: 0,
|
||||
oom_killed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("arraysEqual", () => {
|
||||
it("returns true for identical arrays", () => {
|
||||
const a = [makeSvc({ uid: "a", state: "running" })];
|
||||
expect(arraysEqual(a, a)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different lengths", () => {
|
||||
const a = [makeSvc()];
|
||||
expect(arraysEqual(a, [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when uid differs", () => {
|
||||
const a = [makeSvc({ uid: "a" })];
|
||||
const b = [makeSvc({ uid: "b" })];
|
||||
expect(arraysEqual(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when state differs", () => {
|
||||
const a = [makeSvc({ uid: "a", state: "running" })];
|
||||
const b = [makeSvc({ uid: "a", state: "exited" })];
|
||||
expect(arraysEqual(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when uid and state match despite other differences", () => {
|
||||
const a = [makeSvc({ uid: "a", state: "running", image: "node:18" })];
|
||||
const b = [makeSvc({ uid: "a", state: "running", image: "node:20" })];
|
||||
expect(arraysEqual(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProcessing", () => {
|
||||
it("returns raw unchanged when no processing entries", () => {
|
||||
const raw = [makeSvc()];
|
||||
const processing = new Map<string, ProcessingEntry>();
|
||||
expect(applyProcessing(raw, processing)).toBe(raw);
|
||||
});
|
||||
|
||||
it("shows processing state when minDuration has not elapsed", () => {
|
||||
const now = 10000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("processing");
|
||||
// Entry should NOT be deleted yet
|
||||
expect(processing.has("proj/svc")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears processing when state matches expected after minDuration", () => {
|
||||
const now = 12000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "running" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("running");
|
||||
expect(processing.has("proj/svc")).toBe(false);
|
||||
});
|
||||
|
||||
it("crashed matches expected exited (stop bug)", () => {
|
||||
const now = 12000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("crashed");
|
||||
expect(processing.has("proj/svc")).toBe(false);
|
||||
});
|
||||
|
||||
it("dead matches expected exited", () => {
|
||||
const now = 12000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "dead" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("dead");
|
||||
expect(processing.has("proj/svc")).toBe(false);
|
||||
});
|
||||
|
||||
it("crashed clears processing when expected is running (crash on start)", () => {
|
||||
const now = 12000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("crashed");
|
||||
expect(processing.has("proj/svc")).toBe(false);
|
||||
});
|
||||
|
||||
it("15s timeout clears processing as safety net", () => {
|
||||
const startedAt = 1000;
|
||||
const now = startedAt + 16000; // > 15s
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "running", startedAt, minDuration: 0 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("exited");
|
||||
expect(processing.has("proj/svc")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps processing when state does not match and within timeout", () => {
|
||||
const now = 5000;
|
||||
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/svc", { expected: "running", startedAt: 3000, minDuration: 0 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("processing");
|
||||
expect(processing.has("proj/svc")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not affect services without processing entries", () => {
|
||||
const now = 5000;
|
||||
const raw = [
|
||||
makeSvc({ uid: "proj/a", state: "running" }),
|
||||
makeSvc({ uid: "proj/b", state: "exited" }),
|
||||
];
|
||||
const processing = new Map<string, ProcessingEntry>([
|
||||
["proj/a", { expected: "exited", startedAt: 3000, minDuration: 0 }],
|
||||
]);
|
||||
const result = applyProcessing(raw, processing, now);
|
||||
expect(result[0].state).toBe("processing"); // a is processing
|
||||
expect(result[1].state).toBe("exited"); // b unchanged
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Service } from "../../shared/types";
|
||||
|
||||
export interface ProcessingEntry {
|
||||
expected: Service["state"];
|
||||
startedAt: number;
|
||||
minDuration: number;
|
||||
}
|
||||
|
||||
export function arraysEqual(a: Service[], b: Service[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].uid !== b[i].uid) return false;
|
||||
if (a[i].state !== b[i].state) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyProcessing(
|
||||
raw: Service[],
|
||||
processing: Map<string, ProcessingEntry>,
|
||||
now = Date.now(),
|
||||
): Service[] {
|
||||
if (processing.size === 0) return raw;
|
||||
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;
|
||||
}
|
||||
// Stop/remove expects "exited" but Docker may report "crashed" or "dead" (non-zero exit from SIGTERM/SIGKILL)
|
||||
if (entry.expected === "exited" && (s.state === "crashed" || s.state === "dead" || s.state === "exited")) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||
import type { StatsStore } from "./useStatsStore";
|
||||
|
||||
function arraysEqual(a: Service[], b: Service[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].uid !== b[i].uid) return false;
|
||||
if (a[i].state !== b[i].state) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
||||
|
||||
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -19,6 +11,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
// 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 processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||
const lastRawServicesRef = useRef<Service[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -26,30 +19,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
|
||||
// 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;
|
||||
});
|
||||
return applyProcessingPure(raw, processingRef.current);
|
||||
}, []);
|
||||
|
||||
// Single init call: services + connections + positions
|
||||
@@ -144,7 +114,15 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
break;
|
||||
case "action_error": {
|
||||
processingRef.current.delete(msg.data.uid);
|
||||
setServices((prev) => [...prev]);
|
||||
const clearInterval_ = processingIntervalsRef.current.get(msg.data.uid);
|
||||
if (clearInterval_) { clearInterval(clearInterval_); processingIntervalsRef.current.delete(msg.data.uid); }
|
||||
const raw = lastRawServicesRef.current;
|
||||
if (raw.length > 0) {
|
||||
const incoming = applyProcessing(raw);
|
||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||
} else {
|
||||
setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -178,6 +156,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
// Clean up all processing intervals
|
||||
for (const interval of processingIntervalsRef.current.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
processingIntervalsRef.current.clear();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
@@ -196,25 +179,43 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
||||
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));
|
||||
// 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);
|
||||
}
|
||||
// Clear any existing interval for this uid (e.g. rapid re-clicks)
|
||||
const prevInterval = processingIntervalsRef.current.get(uid);
|
||||
if (prevInterval) clearInterval(prevInterval);
|
||||
|
||||
// Re-evaluate every 1s until processing clears.
|
||||
// Always create interval — even for minDuration=0 — so the 15s timeout safety net works
|
||||
// when the server stops broadcasting (hash unchanged).
|
||||
const interval = setInterval(() => {
|
||||
if (!processingRef.current.has(uid)) {
|
||||
clearInterval(interval);
|
||||
processingIntervalsRef.current.delete(uid);
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < minDuration) return;
|
||||
const raw = lastRawServicesRef.current;
|
||||
if (raw.length === 0) return;
|
||||
const incoming = applyProcessing(raw);
|
||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||
if (!processingRef.current.has(uid)) {
|
||||
clearInterval(interval);
|
||||
processingIntervalsRef.current.delete(uid);
|
||||
}
|
||||
}, 1000);
|
||||
processingIntervalsRef.current.set(uid, interval);
|
||||
}, [applyProcessing]);
|
||||
|
||||
const clearProcessing = useCallback((uid: string) => {
|
||||
processingRef.current.delete(uid);
|
||||
setServices((prev) => [...prev]);
|
||||
}, []);
|
||||
const interval = processingIntervalsRef.current.get(uid);
|
||||
if (interval) { clearInterval(interval); processingIntervalsRef.current.delete(uid); }
|
||||
const raw = lastRawServicesRef.current;
|
||||
if (raw.length > 0) {
|
||||
const incoming = applyProcessing(raw);
|
||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||
}
|
||||
}, [applyProcessing]);
|
||||
|
||||
const getLogsSince = useCallback((uid: string): number | undefined => {
|
||||
return actionTimestamps.current.get(uid);
|
||||
|
||||
@@ -113,6 +113,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
onAction(service.uid, expectedState, minDuration);
|
||||
setInitialLogs([]);
|
||||
clearLogLines();
|
||||
// Unsubscribe log stream to prevent stale lines during the action
|
||||
sendMessage({ type: "unsubscribe_logs" });
|
||||
subscribedRef.current = null;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -135,7 +138,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
setActionLoading(null);
|
||||
setTimeout(() => setActionResult(null), 3000);
|
||||
}
|
||||
}, [service.id, service.uid, token, onAction, clearProcessing]);
|
||||
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]);
|
||||
|
||||
const runExec = useCallback(async () => {
|
||||
if (!execCmd.trim()) return;
|
||||
@@ -174,8 +177,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const since = actionTimestampRef.current;
|
||||
const sinceParam = since ? `&since=${since}` : "";
|
||||
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
|
||||
fetch(`/api/logs/${service.id}?tail=200&since=${since || Math.floor(Date.now() / 1000)}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((lines: LogLine[]) => setInitialLogs(lines))
|
||||
.catch(() => {});
|
||||
@@ -259,10 +261,11 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
||||
}, []);
|
||||
|
||||
// Docker events as special log lines
|
||||
// Docker events as special log lines (only show events since last action, or all if no action)
|
||||
const eventLogLines = useMemo(() => {
|
||||
const sinceTs = actionTimestampRef.current;
|
||||
return events
|
||||
.filter((e) => e.service === service.uid)
|
||||
.filter((e) => e.service === service.uid && (!sinceTs || e.time >= sinceTs))
|
||||
.map((e): LogLine => ({
|
||||
container: service.id,
|
||||
line: `[DOCKER] Container ${e.action}`,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { discoverConnections, isInfraService, isProxyService, isWorkerService } from "./docker";
|
||||
import type { Service } from "../shared/types";
|
||||
|
||||
function makeSvc(overrides: Partial<Service> = {}): Service {
|
||||
return {
|
||||
id: "abc123",
|
||||
uid: "proj/svc",
|
||||
name: "svc",
|
||||
image: "node:20",
|
||||
state: "running",
|
||||
status: "Up 5 minutes",
|
||||
ports: [],
|
||||
networks: ["default"],
|
||||
network_ips: {},
|
||||
project: "proj",
|
||||
compose_file: "",
|
||||
env: [],
|
||||
restart_policy: "",
|
||||
memory_limit: 0,
|
||||
cpu_quota: 0,
|
||||
health_status: "",
|
||||
health_log: [],
|
||||
exit_code: 0,
|
||||
restart_count: 0,
|
||||
oom_killed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isInfraService", () => {
|
||||
it("detects postgres by image", () => {
|
||||
expect(isInfraService(makeSvc({ image: "postgres:16" }))).toEqual({ type: "database", label: "postgres" });
|
||||
});
|
||||
|
||||
it("detects redis by name", () => {
|
||||
expect(isInfraService(makeSvc({ name: "redis-cache" }))).toEqual({ type: "cache", label: "redis" });
|
||||
});
|
||||
|
||||
it("returns null for app service", () => {
|
||||
expect(isInfraService(makeSvc({ name: "backend", image: "node:20" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProxyService", () => {
|
||||
it("detects nginx", () => {
|
||||
expect(isProxyService(makeSvc({ image: "nginx:latest" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects traefik by name", () => {
|
||||
expect(isProxyService(makeSvc({ name: "traefik" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for app service", () => {
|
||||
expect(isProxyService(makeSvc({ name: "api", image: "node:20" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkerService", () => {
|
||||
it("detects celery worker", () => {
|
||||
expect(isWorkerService(makeSvc({ name: "celery-worker" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects beat scheduler", () => {
|
||||
expect(isWorkerService(makeSvc({ name: "celery-beat" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for app service", () => {
|
||||
expect(isWorkerService(makeSvc({ name: "api" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverConnections", () => {
|
||||
it("connects app to infra in same network", async () => {
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["backend"] }),
|
||||
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
expect(conns).toEqual([
|
||||
{ from: "proj/api", to: "proj/db", network: "", type: "database", label: "postgres" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not connect services in different networks", async () => {
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
|
||||
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
expect(conns).toEqual([]);
|
||||
});
|
||||
|
||||
it("connects proxy to app in same network", async () => {
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend"] }),
|
||||
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
expect(conns).toEqual([
|
||||
{ from: "proj/nginx", to: "proj/api", network: "", type: "proxy", label: "upstream" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("connects worker to infra in same network", async () => {
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
|
||||
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
expect(conns).toEqual([
|
||||
{ from: "proj/worker", to: "proj/redis", network: "", type: "cache", label: "broker" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates connections", async () => {
|
||||
// A collector service that is also an app — should only get one connection to db
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/collector", name: "collector", image: "node:20", networks: ["backend"] }),
|
||||
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
// collector is both an app (not infra/proxy/worker) AND matches the collector rule
|
||||
// but deduplication should prevent duplicates
|
||||
const keys = conns.map((c) => `${c.from}:${c.to}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("handles full stack with multiple service types", async () => {
|
||||
const services = [
|
||||
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend", "backend"] }),
|
||||
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend", "backend"] }),
|
||||
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
|
||||
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
|
||||
];
|
||||
const conns = await discoverConnections(services);
|
||||
|
||||
const has = (from: string, to: string) => conns.some((c) => c.from === from && c.to === to);
|
||||
|
||||
expect(has("proj/api", "proj/db")).toBe(true);
|
||||
expect(has("proj/api", "proj/redis")).toBe(true);
|
||||
expect(has("proj/nginx", "proj/api")).toBe(true);
|
||||
expect(has("proj/worker", "proj/redis")).toBe(true);
|
||||
expect(has("proj/worker", "proj/db")).toBe(true);
|
||||
// proxy should NOT connect to infra
|
||||
expect(has("proj/nginx", "proj/db")).toBe(false);
|
||||
expect(has("proj/nginx", "proj/redis")).toBe(false);
|
||||
});
|
||||
});
|
||||
+12
-6
@@ -4,7 +4,9 @@ import type { Service, Connection, LogLine } from "../shared/types";
|
||||
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
|
||||
|
||||
if (!fs.existsSync(DOCKER_SOCKET)) {
|
||||
const isTest = process.env.NODE_ENV === "test" || !!process.env.VITEST;
|
||||
|
||||
if (!isTest && !fs.existsSync(DOCKER_SOCKET)) {
|
||||
console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`);
|
||||
console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
|
||||
process.exit(1);
|
||||
@@ -107,7 +109,7 @@ const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "tar
|
||||
|
||||
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
|
||||
|
||||
function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||
export function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||
const img = svc.image.toLowerCase();
|
||||
const name = svc.name.toLowerCase();
|
||||
for (const p of INFRA_PATTERNS) {
|
||||
@@ -118,13 +120,13 @@ function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProxyService(svc: Service): boolean {
|
||||
export function isProxyService(svc: Service): boolean {
|
||||
const img = svc.image.toLowerCase();
|
||||
const name = svc.name.toLowerCase();
|
||||
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
|
||||
}
|
||||
|
||||
function isWorkerService(svc: Service): boolean {
|
||||
export function isWorkerService(svc: Service): boolean {
|
||||
const name = svc.name.toLowerCase();
|
||||
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
|
||||
}
|
||||
@@ -201,10 +203,14 @@ export async function getContainerLogs(id: string, tail = 200, since?: number):
|
||||
const opts: Record<string, any> = {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
timestamps: true,
|
||||
};
|
||||
if (since) opts.since = since;
|
||||
if (since) {
|
||||
// When filtering by time, use since only (no tail limit) — Docker applies tail before since
|
||||
opts.since = since;
|
||||
} else {
|
||||
opts.tail = tail;
|
||||
}
|
||||
const logBuffer = await container.logs(opts);
|
||||
|
||||
const lines: LogLine[] = [];
|
||||
|
||||
+12
-4
@@ -124,7 +124,7 @@ app.post("/api/containers/:id/stop", async (c) => {
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.stop();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" });
|
||||
@@ -138,7 +138,7 @@ app.post("/api/containers/:id/start", async (c) => {
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.start();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" });
|
||||
@@ -152,7 +152,7 @@ app.post("/api/containers/:id/restart", async (c) => {
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.restart();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to restart container" }, 500);
|
||||
@@ -464,6 +464,14 @@ function scheduleRefresh() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Immediate refresh after action endpoints (container already changed state)
|
||||
function immediateRefresh() {
|
||||
lastServicesHash = "";
|
||||
clearTimeout(refreshTimer);
|
||||
clearTimeout(retryTimer);
|
||||
refreshServices();
|
||||
}
|
||||
|
||||
watchDockerEvents((event) => {
|
||||
broadcast({ type: "docker_event", data: event });
|
||||
scheduleRefresh();
|
||||
@@ -516,7 +524,7 @@ const server = Bun.serve({
|
||||
},
|
||||
message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as unknown as ArrayBuffer));
|
||||
const native = ws as unknown as WebSocket;
|
||||
|
||||
// Handle authentication via first message
|
||||
|
||||
Reference in New Issue
Block a user