(d.activeHandles || []);
+ const highlighted = d.highlighted;
const hdot = (id: string) => {
if (activeHandles.has(id)) {
return highlighted
@@ -118,7 +122,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
return (
`${p.host}:${p.container}`).join(", ") || "none"}`}
+ title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
className={`relative rounded-xl border border-slate-700/80 ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
transition-all duration-300 ${flashClass}`}
diff --git a/src/server/docker.ts b/src/server/docker.ts
index b63108d..c6b91c0 100644
--- a/src/server/docker.ts
+++ b/src/server/docker.ts
@@ -1,7 +1,16 @@
import Docker from "dockerode";
+import fs from "fs";
import type { Service, Connection, LogLine } from "../shared/types";
-const docker = new Docker({ socketPath: "/var/run/docker.sock" });
+const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
+
+if (!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);
+}
+
+const docker = new Docker({ socketPath: DOCKER_SOCKET });
export { docker };
@@ -191,6 +200,10 @@ export function streamContainerLogs(
let stream: NodeJS.ReadableStream | null = null;
let destroyed = false;
+ const destroyStream = (s: unknown) => {
+ if (s && typeof (s as any).destroy === "function") (s as any).destroy();
+ };
+
container.logs({
stdout: true,
stderr: true,
@@ -198,16 +211,19 @@ export function streamContainerLogs(
since: Math.floor(Date.now() / 1000),
timestamps: true,
}).then((s) => {
+ stream = s as unknown as NodeJS.ReadableStream;
+
if (destroyed) {
- if (s && typeof (s as any).destroy === "function") (s as any).destroy();
+ destroyStream(stream);
+ stream = null;
return;
}
- stream = s as unknown as NodeJS.ReadableStream;
// 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) {
@@ -232,13 +248,16 @@ export function streamContainerLogs(
});
}
});
- }).catch(() => {});
+ }).catch((err) => {
+ console.error(`Failed to stream logs for ${id}:`, err);
+ });
return {
destroy() {
destroyed = true;
- if (stream && typeof (stream as any).destroy === "function") {
- (stream as any).destroy();
+ if (stream) {
+ destroyStream(stream);
+ stream = null;
}
},
};
diff --git a/src/server/index.ts b/src/server/index.ts
index 39a4355..20dc198 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
+import { cors } from "hono/cors";
import path from "path";
import fs from "fs";
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
@@ -9,6 +10,9 @@ import type { WSMessage } from "../shared/types";
const app = new Hono();
+// ── CORS ──
+app.use("/api/*", cors());
+
// ── CLI args ──
const args = process.argv.slice(2);
const ALL = args.includes("--all");
@@ -23,6 +27,8 @@ const PROJECTS = projectsFlag
const PORT = parseInt(process.env.PORT || "9470");
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
+const POLL_INTERVAL_MS = 5000;
+const WS_RECONNECT_MS = 3000;
// ── Auth middleware ──
if (AUTH_TOKEN) {
@@ -60,7 +66,10 @@ app.get("/api/flows", (c) => {
app.get("/api/logs/:id", async (c) => {
const id = c.req.param("id");
- const tail = parseInt(c.req.query("tail") || "200");
+ if (!/^[a-f0-9]{12,64}$/.test(id)) {
+ return c.json({ error: "Invalid container ID" }, 400);
+ }
+ const tail = Math.min(Math.max(parseInt(c.req.query("tail") || "200") || 200, 1), 5000);
try {
const lines = await getContainerLogs(id, tail);
return c.json(lines);
@@ -78,7 +87,9 @@ app.get("/api/positions", (c) => {
const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
return c.json(data);
}
- } catch {}
+ } catch (err) {
+ console.error("Failed to read positions file:", err);
+ }
return c.json({});
});
@@ -98,11 +109,17 @@ app.get("/*", serveStatic({ root: "./dist", path: "index.html" }));
// ── WebSocket ──
const clients = new Set();
+const authenticatedClients = new Set();
const logStreams = new Map void }>();
+function isAuthenticated(ws: WebSocket): boolean {
+ return !AUTH_TOKEN || authenticatedClients.has(ws);
+}
+
function broadcast(msg: WSMessage) {
const data = JSON.stringify(msg);
for (const ws of clients) {
+ if (!isAuthenticated(ws)) continue;
try {
ws.send(data);
} catch {}
@@ -112,8 +129,12 @@ function broadcast(msg: WSMessage) {
function cleanupLogStream(ws: WebSocket) {
const stream = logStreams.get(ws);
if (stream) {
- stream.destroy();
logStreams.delete(ws);
+ try {
+ stream.destroy();
+ } catch (err) {
+ console.error("Failed to destroy log stream:", err);
+ }
}
}
@@ -150,7 +171,7 @@ setInterval(async () => {
} catch (err) {
console.error("Poll error:", err);
}
-}, 5000);
+}, POLL_INTERVAL_MS);
// ── Start ──
const server = Bun.serve({
@@ -159,12 +180,8 @@ const server = Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
- // WebSocket upgrade
+ // WebSocket upgrade (auth handled via first message)
if (url.pathname === "/ws") {
- const token = url.searchParams.get("token") || "";
- if (AUTH_TOKEN && token !== AUTH_TOKEN) {
- return new Response("Unauthorized", { status: 401 });
- }
if (server.upgrade(req)) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
@@ -175,24 +192,47 @@ const server = Bun.serve({
open(ws) {
const native = ws as unknown as WebSocket;
clients.add(native);
- // Send flows to new client
- const flowsData = { flows: getFlows(), settings: getSettings() };
- if (flowsData.flows.length > 0) {
- try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
+
+ if (!AUTH_TOKEN) {
+ // No auth required — send data immediately
+ const flowsData = { flows: getFlows(), settings: getSettings() };
+ if (flowsData.flows.length > 0) {
+ try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
+ }
}
},
close(ws) {
const native = ws as unknown as WebSocket;
cleanupLogStream(native);
clients.delete(native);
+ authenticatedClients.delete(native);
},
message(ws, message) {
try {
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
const native = ws as unknown as WebSocket;
+ // Handle authentication via first message
+ if (msg.type === "auth") {
+ if (msg.token === AUTH_TOKEN) {
+ authenticatedClients.add(native);
+ native.send(JSON.stringify({ type: "auth_ok" }));
+ // Send initial data after auth
+ const flowsData = { flows: getFlows(), settings: getSettings() };
+ if (flowsData.flows.length > 0) {
+ native.send(JSON.stringify({ type: "flows", data: flowsData }));
+ }
+ } else {
+ native.send(JSON.stringify({ type: "auth_error" }));
+ native.close();
+ }
+ return;
+ }
+
+ // Reject messages from unauthenticated clients
+ if (!isAuthenticated(native)) return;
+
if (msg.type === "subscribe_logs" && msg.container) {
- // Clean up any existing stream first
cleanupLogStream(native);
const stream = streamContainerLogs(msg.container, (line) => {
@@ -212,13 +252,15 @@ const server = Bun.serve({
});
}
}
- } catch {}
+ } catch (err) {
+ console.error("Failed to handle WS message:", err);
+ }
},
},
});
const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`;
-console.log(`\n Alteonx DockerFlow`);
+console.log(`\n Flowteon`);
console.log(` → http://${HOST}:${PORT}`);
console.log(` → Mode: ${mode}`);
console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`);
diff --git a/src/server/watcher.ts b/src/server/watcher.ts
index 6212108..5ac21ee 100644
--- a/src/server/watcher.ts
+++ b/src/server/watcher.ts
@@ -44,28 +44,41 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
return;
}
+ let buffer = "";
+
stream.on("data", (chunk: Buffer) => {
- try {
- const event = JSON.parse(chunk.toString());
- if (event.Type !== "container") return;
+ buffer += chunk.toString();
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || ""; // keep incomplete last line in buffer
- const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
- if (!["start", "stop", "die", "restart", "health_status"].includes(action)) return;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
- const svcName =
- event.Actor?.Attributes?.["com.docker.compose.service"] ||
- event.Actor?.Attributes?.name ||
- "unknown";
- const svcProject =
- event.Actor?.Attributes?.["com.docker.compose.project"] ||
- "standalone";
- onEvent({
- type: "docker",
- action,
- service: `${svcProject}/${svcName}`,
- time: event.time || Date.now() / 1000,
- });
- } catch {}
+ try {
+ const event = JSON.parse(trimmed);
+ if (event.Type !== "container") continue;
+
+ const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
+ if (!["start", "stop", "die", "restart", "health_status"].includes(action)) continue;
+
+ const svcName =
+ event.Actor?.Attributes?.["com.docker.compose.service"] ||
+ event.Actor?.Attributes?.name ||
+ "unknown";
+ const svcProject =
+ event.Actor?.Attributes?.["com.docker.compose.project"] ||
+ "standalone";
+ onEvent({
+ type: "docker",
+ action,
+ service: `${svcProject}/${svcName}`,
+ time: event.time || Date.now() / 1000,
+ });
+ } catch {
+ // Ignore malformed lines
+ }
+ }
});
});
}
diff --git a/vite.config.ts b/vite.config.ts
index a4b50d7..37f9315 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,7 +10,8 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
- port: 5174,
+ port: 9420,
+ host: "0.0.0.0",
proxy: {
"/api": "http://localhost:9470",
"/ws": {