This commit is contained in:
RGJorge
2026-03-22 10:31:19 +00:00
parent 8703f28856
commit cd7786ef66
10 changed files with 638 additions and 9 deletions
+49
View File
@@ -0,0 +1,49 @@
flows:
web_request:
name: "Request Web"
description: "Usuario accede al frontend via nginx"
color: "#3b82f6"
speed: 1.5
path: [nginx, frontend]
api_request:
name: "API Request"
description: "Request del frontend al backend con auth y DB"
color: "#22d3ee"
speed: 1.4
path: [nginx, backend, db, backend, nginx]
auth_flow:
name: "Autenticación"
description: "Login/registro pasando por auth service"
color: "#a855f7"
speed: 1.3
path: [nginx, auth, ninja-redis, auth, nginx]
cached_query:
name: "Consulta con Cache"
description: "Backend consulta Redis antes de ir a DB"
color: "#f59e0b"
speed: 1.5
path: [nginx, backend, ninja-redis, backend, db, backend, nginx]
background_job:
name: "Tarea en Background"
description: "Celery beat agenda tareas, worker las ejecuta"
color: "#ec4899"
speed: 1.8
path: [celery-beat, ninja-redis, celery-worker, db]
data_collection:
name: "Recolección de Datos"
description: "Collector guarda en DB e invalida cache en Redis"
color: "#10b981"
speed: 2.0
path: [collector, db, collector, ninja-redis]
settings:
particle_size: 2
trail: true
trail_opacity: 0.3
glow: true
max_particles: 50
+79 -2
View File
@@ -19,8 +19,11 @@ import { ServiceNode } from "./nodes/ServiceNode";
import { GroupNode } from "./nodes/GroupNode"; import { GroupNode } from "./nodes/GroupNode";
import { useDocker } from "./hooks/useDocker"; import { useDocker } from "./hooks/useDocker";
import { buildLayout, computeEdges } from "./engine/layout"; import { buildLayout, computeEdges } from "./engine/layout";
import { ParticleEngine } from "./engine/particles";
import { ParticleOverlay } from "./components/ParticleOverlay";
import { LogPanel } from "./panels/LogPanel"; import { LogPanel } from "./panels/LogPanel";
import type { Service } from "../shared/types"; import { FlowPanel } from "./panels/FlowPanel";
import type { Service, Flow } from "../shared/types";
function OffsetEdge(props: EdgeProps) { function OffsetEdge(props: EdgeProps) {
const offset = (props.data as any)?.offset ?? 0; const offset = (props.data as any)?.offset ?? 0;
@@ -207,7 +210,13 @@ export default function App() {
} }
function Dashboard({ token }: { token: string }) { function Dashboard({ token }: { token: string }) {
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines } = useDocker(token); const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn } = useDocker(token);
const engineRef = useRef<ParticleEngine>(null);
if (!engineRef.current) {
engineRef.current = new ParticleEngine();
}
const engine = engineRef.current;
engine.maxParticles = flowSettings.max_particles;
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);
@@ -531,6 +540,64 @@ function Dashboard({ token }: { token: string }) {
}, 1200); }, 1200);
}, [events]); }, [events]);
// Service name → UID map for flow path resolution
const serviceNameToUid = useMemo(() => {
const map = new Map<string, string>();
for (const s of services) {
map.set(s.name, s.uid);
}
return map;
}, [services]);
const resolveFlowPath = useCallback((path: string[]): string[] => {
return path.map((name) => serviceNameToUid.get(name) || name);
}, [serviceNameToUid]);
const handleSimulate = useCallback((flow: Flow) => {
const pathUids = resolveFlowPath(flow.path);
engine.spawn(flow.id, flow.color, flow.speed, pathUids);
// Also broadcast via WS so other clients see it
sendMessage({ type: "simulate_flow", flowId: flow.id });
}, [resolveFlowPath, engine, sendMessage]);
// Handle particle spawns from other clients via WS
useEffect(() => {
return onParticleSpawn((data) => {
const pathUids = resolveFlowPath(data.path);
engine.spawn(data.flowId, data.color, data.speed, pathUids);
});
}, [onParticleSpawn, resolveFlowPath, engine]);
// Handle particle node hits — flash node with particle color
const nodeHitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const handleNodeHits = useCallback((hits: { nodeId: string; color: string }[]) => {
for (const hit of hits) {
// Clear existing timer for this node
const existing = nodeHitTimers.current.get(hit.nodeId);
if (existing) clearTimeout(existing);
setNodes((prev) =>
prev.map((n) =>
n.id === hit.nodeId
? { ...n, data: { ...n.data, particleGlow: hit.color } }
: n
)
);
const timer = setTimeout(() => {
setNodes((prev) =>
prev.map((n) =>
n.id === hit.nodeId
? { ...n, data: { ...n.data, particleGlow: "" } }
: n
)
);
nodeHitTimers.current.delete(hit.nodeId);
}, 500);
nodeHitTimers.current.set(hit.nodeId, timer);
}
}, [setNodes]);
const runningCount = filteredServices.filter((s) => s.state === "running").length; const runningCount = filteredServices.filter((s) => s.state === "running").length;
// Highlight edges connected to selected node, dim the rest // Highlight edges connected to selected node, dim the rest
@@ -598,6 +665,15 @@ function Dashboard({ token }: { token: string }) {
</div> </div>
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
{/* Flow panel */}
<FlowPanel
flows={flows}
settings={flowSettings}
engine={engine}
services={services}
onSimulate={handleSimulate}
/>
{/* Project filter dropdown */} {/* Project filter dropdown */}
{projects.length > 1 && ( {projects.length > 1 && (
<div className="relative" ref={filterRef}> <div className="relative" ref={filterRef}>
@@ -696,6 +772,7 @@ function Dashboard({ token }: { token: string }) {
maxZoom={2.5} maxZoom={2.5}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
<Background color="#1e293b" gap={24} size={1} /> <Background color="#1e293b" gap={24} size={1} />
<Controls position="bottom-left" /> <Controls position="bottom-left" />
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useCallback } from "react";
import type { ParticleEngine } from "../engine/particles";
import type { FlowSettings } from "../../shared/types";
interface ParticleOverlayProps {
engine: ParticleEngine;
settings: FlowSettings;
onNodeHits?: (hits: { nodeId: string; color: string }[]) => void;
}
export function ParticleOverlay({ engine, settings, onNodeHits }: ParticleOverlayProps) {
const svgRef = useRef<SVGSVGElement>(null);
const rafRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
// Cache edge path lookups to avoid querying DOM every frame
const pathCache = useRef<Map<string, SVGPathElement | null>>(new Map());
const findEdgePath = useCallback((edgeId: string): SVGPathElement | null => {
if (pathCache.current.has(edgeId)) return pathCache.current.get(edgeId)!;
const el = document.querySelector(`[data-testid="rf__edge-${edgeId}"]`);
const pathEl = (el?.querySelector(".react-flow__edge-path") as SVGPathElement) || null;
pathCache.current.set(edgeId, pathEl);
// Invalidate cache after a bit in case edges re-render
setTimeout(() => pathCache.current.delete(edgeId), 2000);
return pathEl;
}, []);
const loop = useCallback((timestamp: number) => {
if (!lastTimeRef.current) lastTimeRef.current = timestamp;
const delta = Math.min(timestamp - lastTimeRef.current, 100);
lastTimeRef.current = timestamp;
engine.tick(delta);
// Notify node hits
const hits = engine.getNodeHits();
if (hits.length > 0 && onNodeHits) {
onNodeHits(hits);
}
const svg = svgRef.current;
if (!svg) {
rafRef.current = requestAnimationFrame(loop);
return;
}
// Clear previous particles (keep <defs>)
const defs = svg.firstChild;
while (svg.lastChild && svg.lastChild !== defs) {
svg.removeChild(svg.lastChild);
}
const edgeParticles = engine.getEdgeParticles();
if (edgeParticles.size === 0) {
rafRef.current = requestAnimationFrame(loop);
return;
}
const size = settings.particle_size;
const svgCTM = svg.getScreenCTM();
if (!svgCTM) {
rafRef.current = requestAnimationFrame(loop);
return;
}
const svgCTMInverse = svgCTM.inverse();
// Track which edges we already rendered particles for (avoid duplicates from forward+reverse)
const rendered = new Set<string>();
for (const [edgeId, pList] of edgeParticles) {
const pathEl = findEdgePath(edgeId);
if (!pathEl) continue;
const pathCTM = pathEl.getScreenCTM();
if (!pathCTM) continue;
const totalLength = pathEl.getTotalLength();
for (const p of pList) {
// Unique key to avoid rendering same particle twice (forward+reverse entries)
const particleKey = `${p.color}-${p.progress.toFixed(4)}-${p.reverse}`;
if (rendered.has(particleKey)) continue;
rendered.add(particleKey);
// If reverse, traverse path backwards
const t = p.reverse ? (1 - p.progress) : p.progress;
const point = pathEl.getPointAtLength(t * totalLength);
// Convert: path-local → screen → our SVG coords
const screenX = pathCTM.a * point.x + pathCTM.c * point.y + pathCTM.e;
const screenY = pathCTM.b * point.x + pathCTM.d * point.y + pathCTM.f;
const x = svgCTMInverse.a * screenX + svgCTMInverse.c * screenY + svgCTMInverse.e;
const y = svgCTMInverse.b * screenX + svgCTMInverse.d * screenY + svgCTMInverse.f;
// Outer glow
if (settings.trail) {
const glow = document.createElementNS("http://www.w3.org/2000/svg", "circle");
glow.setAttribute("cx", String(x));
glow.setAttribute("cy", String(y));
glow.setAttribute("r", String(size * 2.5));
glow.setAttribute("fill", p.color);
glow.setAttribute("opacity", String(settings.trail_opacity * 0.25));
svg.appendChild(glow);
}
// Main circle
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", String(x));
circle.setAttribute("cy", String(y));
circle.setAttribute("r", String(size));
circle.setAttribute("fill", p.color);
if (settings.glow) {
circle.setAttribute("filter", "url(#particle-glow)");
}
svg.appendChild(circle);
}
}
rafRef.current = requestAnimationFrame(loop);
}, [engine, settings, onNodeHits, findEdgePath]);
useEffect(() => {
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [loop]);
return (
<svg
ref={svgRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 10,
overflow: "visible",
}}
>
<defs>
<filter id="particle-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
);
}
+118
View File
@@ -0,0 +1,118 @@
export interface Particle {
id: string;
flowId: string;
color: string;
path: string[]; // UIDs
currentStep: number;
progress: number; // 0-1 within current edge
speed: number;
paused: number; // remaining pause time in ms at node
}
export interface NodeHit {
nodeId: string;
color: string;
}
let idCounter = 0;
const PAUSE_AT_NODE_MS = 400;
export class ParticleEngine {
particles: Particle[] = [];
maxParticles = 50;
nodeHits: NodeHit[] = [];
spawn(flowId: string, color: string, speed: number, pathUids: string[]): void {
if (pathUids.length < 2) return;
if (this.particles.length >= this.maxParticles) return;
this.particles.push({
id: `p-${++idCounter}`,
flowId,
color,
path: pathUids,
currentStep: 0,
progress: 0,
speed,
paused: 0,
});
// First node hit
this.nodeHits.push({ nodeId: pathUids[0]!, color });
}
tick(deltaMs: number): void {
this.nodeHits = [];
for (const p of this.particles) {
// If paused at a node, count down
if (p.paused > 0) {
p.paused -= deltaMs;
if (p.paused > 0) continue;
// Resume: advance to next step
p.currentStep++;
p.progress = 0;
continue;
}
p.progress += deltaMs / (p.speed * 1000);
if (p.progress >= 1) {
// Arrived at next node — pause there
const arrivedAt = p.path[p.currentStep + 1];
if (arrivedAt) {
this.nodeHits.push({ nodeId: arrivedAt, color: p.color });
}
p.progress = 1;
p.paused = PAUSE_AT_NODE_MS;
}
}
// Remove completed particles (past last edge and done pausing)
this.particles = this.particles.filter((p) => {
if (p.currentStep >= p.path.length - 1) return false;
return true;
});
}
getEdgeParticles(): Map<string, { progress: number; color: string; reverse: boolean }[]> {
const map = new Map<string, { progress: number; color: string; reverse: boolean }[]>();
for (const p of this.particles) {
if (p.paused > 0) continue; // paused at node, don't render on edge
if (p.currentStep >= p.path.length - 1) continue;
const from = p.path[p.currentStep]!;
const to = p.path[p.currentStep + 1]!;
// Try forward edge first, then reverse
const forwardId = `${from}-${to}`;
const reverseId = `${to}-${from}`;
// We'll try both — the renderer will check which exists in DOM
const edgeId = forwardId;
const reverseEdgeId = reverseId;
if (!map.has(edgeId)) map.set(edgeId, []);
map.get(edgeId)!.push({ progress: p.progress, color: p.color, reverse: false });
// Also register reverse so renderer can pick whichever edge exists
if (!map.has(reverseEdgeId)) map.set(reverseEdgeId, []);
map.get(reverseEdgeId)!.push({ progress: p.progress, color: p.color, reverse: true });
}
return map;
}
getNodeHits(): NodeHit[] {
return this.nodeHits;
}
clear(): void {
this.particles = [];
this.nodeHits = [];
}
get count(): number {
return this.particles.length;
}
}
+25 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types"; import type { Service, Connection, Stats, DockerEvent, LogLine, Flow, FlowSettings, WSMessage } from "../../shared/types";
function arraysEqual<T extends { uid?: string; name?: string }>(a: T[], b: T[]): boolean { function arraysEqual<T extends { uid?: string; name?: string }>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@@ -17,6 +17,11 @@ export function useDocker(token = "") {
const [statsVersion, setStatsVersion] = useState(0); const [statsVersion, setStatsVersion] = useState(0);
const [events, setEvents] = useState<DockerEvent[]>([]); const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]); const [logLines, setLogLines] = useState<LogLine[]>([]);
const [flows, setFlows] = useState<Flow[]>([]);
const [flowSettings, setFlowSettings] = useState<FlowSettings>({
particle_size: 5, trail: true, trail_opacity: 0.3, glow: true, max_particles: 50,
});
const particleSpawnCallbacks = useRef<Set<(data: { flowId: string; color: string; speed: number; path: string[] }) => void>>(new Set());
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);
@@ -29,9 +34,14 @@ export function useDocker(token = "") {
Promise.all([ Promise.all([
fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []), fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []),
fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []), fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []),
]).then(([svcs, conns]) => { fetch("/api/flows", { headers }).then((r) => r.ok ? r.json() : null),
]).then(([svcs, conns, flowData]) => {
setServices((prev) => prev.length === 0 ? svcs : prev); setServices((prev) => prev.length === 0 ? svcs : prev);
setConnections((prev) => prev.length === 0 ? conns : prev); setConnections((prev) => prev.length === 0 ? conns : prev);
if (flowData?.flows) {
setFlows((prev) => prev.length === 0 ? flowData.flows : prev);
setFlowSettings(flowData.settings);
}
}).catch(() => {}); }).catch(() => {});
}, [token]); }, [token]);
@@ -94,6 +104,13 @@ export function useDocker(token = "") {
return next.length > 2000 ? next.slice(-1500) : next; return next.length > 2000 ? next.slice(-1500) : next;
}); });
break; break;
case "flows":
setFlows(msg.data.flows);
setFlowSettings(msg.data.settings);
break;
case "particle_spawn":
for (const cb of particleSpawnCallbacks.current) cb(msg.data);
break;
} }
} catch {} } catch {}
}; };
@@ -134,5 +151,10 @@ export function useDocker(token = "") {
const clearLogLines = useCallback(() => setLogLines([]), []); const clearLogLines = useCallback(() => setLogLines([]), []);
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines }; const onParticleSpawn = useCallback((cb: (data: { flowId: string; color: string; speed: number; path: string[] }) => void) => {
particleSpawnCallbacks.current.add(cb);
return () => { particleSpawnCallbacks.current.delete(cb); };
}, []);
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn };
} }
+7 -2
View File
@@ -101,6 +101,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
const s = stateStyles[d.state] || stateStyles.exited; const s = stateStyles[d.state] || stateStyles.exited;
const { Icon, color: iconColor } = guessIcon(d.image, d.label); const { Icon, color: iconColor } = guessIcon(d.image, d.label);
const flashClass = d.flash || ""; const flashClass = d.flash || "";
const particleGlow = (d as any).particleGlow || "";
const activeHandles = new Set<string>((d as any).activeHandles || []); const activeHandles = new Set<string>((d as any).activeHandles || []);
const highlighted = (d as any).highlighted; const highlighted = (d as any).highlighted;
const hdot = (id: string) => { const hdot = (id: string) => {
@@ -119,9 +120,13 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
<div <div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${(d as any).id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`} title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${(d as any).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 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 ${s.ring} shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
transition-all duration-500 ${flashClass} transition-all duration-300 ${flashClass}
${d.state === "running" ? "node-pulse-running" : ""}`} ${d.state === "running" ? "node-pulse-running" : ""}`}
style={particleGlow ? {
boxShadow: `0 0 20px ${particleGlow}60, 0 0 40px ${particleGlow}30, inset 0 0 15px ${particleGlow}15`,
borderColor: particleGlow,
} : undefined}
> >
{/* Top handles — left offset, transform centered horizontally */} {/* Top handles — left offset, transform centered horizontally */}
{offsets.map((o, i) => ( {offsets.map((o, i) => (
+111
View File
@@ -0,0 +1,111 @@
import { useState, useRef, useEffect } from "react";
import { Play, Trash2, ChevronDown, Zap } from "lucide-react";
import type { Flow, FlowSettings } from "../../shared/types";
import type { ParticleEngine } from "../engine/particles";
import type { Service } from "../../shared/types";
interface FlowPanelProps {
flows: Flow[];
settings: FlowSettings;
engine: ParticleEngine;
services: Service[];
onSimulate: (flow: Flow) => void;
}
export function FlowPanel({ flows, settings, engine, services, onSimulate }: FlowPanelProps) {
const [open, setOpen] = useState(false);
const [particleCount, setParticleCount] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
// Update particle count periodically
useEffect(() => {
const interval = setInterval(() => {
setParticleCount(engine.count);
}, 200);
return () => clearInterval(interval);
}, [engine]);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as HTMLElement)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
if (flows.length === 0) return null;
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 hover:bg-slate-700/80 px-3 py-1.5 rounded-md transition-colors"
>
<Play size={14} className="text-cyan-400" />
Flujos
{particleCount > 0 && (
<span className="text-xs bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-full font-mono">
{particleCount}
</span>
)}
<ChevronDown
size={14}
className={`text-slate-500 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[240px] z-[9999]">
<div className="px-3.5 py-2 border-b border-slate-700/50">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">
Simulaciones de Flujo
</span>
</div>
{flows.map((flow) => (
<button
key={flow.id}
onClick={() => onSimulate(flow)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors group"
>
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: flow.color }}
/>
<span className="text-slate-200 truncate">{flow.name}</span>
<Play
size={12}
className="text-slate-600 group-hover:text-cyan-400 ml-auto shrink-0 transition-colors"
/>
</button>
))}
<div className="border-t border-slate-700/50 mt-1 pt-1 flex gap-1 px-2">
<button
onClick={() => {
for (const flow of flows) onSimulate(flow);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-slate-400 hover:text-cyan-400 hover:bg-slate-700/60 rounded transition-colors flex-1"
>
<Zap size={12} />
Simular Todo
</button>
<button
onClick={() => {
engine.clear();
setParticleCount(0);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-slate-400 hover:text-red-400 hover:bg-slate-700/60 rounded transition-colors flex-1"
>
<Trash2 size={12} />
Limpiar
</button>
</div>
</div>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import fs from "fs";
import path from "path";
import yaml from "yaml";
import type { Flow, FlowSettings } from "../shared/types";
const DEFAULT_SETTINGS: FlowSettings = {
particle_size: 5,
trail: true,
trail_opacity: 0.3,
glow: true,
max_particles: 50,
};
let flows: Flow[] = [];
let settings: FlowSettings = { ...DEFAULT_SETTINGS };
export function loadFlows(): void {
const filePath = path.join(process.cwd(), "flows.yaml");
if (!fs.existsSync(filePath)) {
flows = [];
settings = { ...DEFAULT_SETTINGS };
return;
}
try {
const raw = yaml.parse(fs.readFileSync(filePath, "utf-8"));
if (raw?.flows) {
flows = Object.entries(raw.flows).map(([id, def]: [string, any]) => ({
id,
name: def.name || id,
description: def.description || "",
color: def.color || "#3b82f6",
speed: def.speed ?? 0.8,
path: def.path || [],
}));
}
if (raw?.settings) {
settings = { ...DEFAULT_SETTINGS, ...raw.settings };
}
} catch (err) {
console.error("Failed to parse flows.yaml:", err);
flows = [];
settings = { ...DEFAULT_SETTINGS };
}
}
export function getFlows(): Flow[] {
return flows;
}
export function getSettings(): FlowSettings {
return settings;
}
+23 -1
View File
@@ -4,6 +4,7 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
import { pollStats, watchDockerEvents } from "./watcher"; import { pollStats, watchDockerEvents } from "./watcher";
import { loadFlows, getFlows, getSettings } from "./flows";
import type { WSMessage } from "../shared/types"; import type { WSMessage } from "../shared/types";
const app = new Hono(); const app = new Hono();
@@ -50,6 +51,13 @@ app.get("/api/connections", async (c) => {
app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS })); app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS }));
// ── Flows ──
loadFlows();
app.get("/api/flows", (c) => {
return c.json({ flows: getFlows(), settings: getSettings() });
});
app.get("/api/logs/:id", async (c) => { app.get("/api/logs/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const tail = parseInt(c.req.query("tail") || "200"); const tail = parseInt(c.req.query("tail") || "200");
@@ -165,7 +173,13 @@ const server = Bun.serve({
}, },
websocket: { websocket: {
open(ws) { open(ws) {
clients.add(ws as unknown as WebSocket); 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 {}
}
}, },
close(ws) { close(ws) {
const native = ws as unknown as WebSocket; const native = ws as unknown as WebSocket;
@@ -189,6 +203,14 @@ const server = Bun.serve({
logStreams.set(native, stream); logStreams.set(native, stream);
} else if (msg.type === "unsubscribe_logs") { } else if (msg.type === "unsubscribe_logs") {
cleanupLogStream(native); cleanupLogStream(native);
} else if (msg.type === "simulate_flow" && msg.flowId) {
const flow = getFlows().find((f) => f.id === msg.flowId);
if (flow) {
broadcast({
type: "particle_spawn",
data: { flowId: flow.id, color: flow.color, speed: flow.speed, path: flow.path },
});
}
} }
} catch {} } catch {}
}, },
+21 -1
View File
@@ -40,6 +40,23 @@ export interface LogLine {
stream: "stdout" | "stderr"; stream: "stdout" | "stderr";
} }
export interface Flow {
id: string;
name: string;
description?: string;
color: string;
speed: number;
path: string[];
}
export interface FlowSettings {
particle_size: number;
trail: boolean;
trail_opacity: number;
glow: boolean;
max_particles: number;
}
export type WSMessage = export type WSMessage =
| { type: "services"; data: Service[] } | { type: "services"; data: Service[] }
| { type: "connections"; data: Connection[] } | { type: "connections"; data: Connection[] }
@@ -47,4 +64,7 @@ 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: "flows"; data: { flows: Flow[]; settings: FlowSettings } }
| { type: "simulate_flow"; flowId: string }
| { type: "particle_spawn"; data: { flowId: string; color: string; speed: number; path: string[] } };