mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.12
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Dockerflow (Flowteon)
|
||||||
|
|
||||||
|
## Nomenclatura UI
|
||||||
|
|
||||||
|
- **Nodo** — tarjeta de servicio en el canvas (`ServiceNode.tsx`)
|
||||||
|
- **Panel** — panel lateral izquierdo con detalles del servicio (`DetailPanel.tsx`)
|
||||||
|
- **Canvas** — mesa de trabajo donde se ven los nodos y conexiones (ReactFlow)
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend:** React + ReactFlow + Tailwind CSS
|
||||||
|
- **Backend:** Hono + Bun
|
||||||
|
- **Docker:** dockerode para comunicacion con Docker API
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
- `src/client/` — frontend React
|
||||||
|
- `nodes/` — componentes de nodos (ServiceNode, GroupNode)
|
||||||
|
- `panels/` — paneles (DetailPanel)
|
||||||
|
- `hooks/` — hooks (useDocker)
|
||||||
|
- `engine/` — layout y particulas
|
||||||
|
- `components/` — componentes generales
|
||||||
|
- `src/server/` — backend Hono
|
||||||
|
- `index.ts` — servidor principal, WebSocket, API REST
|
||||||
|
- `docker.ts` — interaccion con Docker
|
||||||
|
- `watcher.ts` — polling de stats y eventos
|
||||||
|
- `flows.ts` — flujos de particulas
|
||||||
|
- `src/shared/` — tipos compartidos
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
- `bun run dev` — desarrollo (servidor + cliente)
|
||||||
|
- `bun run build` — build de produccion
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
+2
-64
@@ -16,14 +16,12 @@ 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, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
||||||
import { ParticleEngine } from "./engine/particles";
|
|
||||||
import { ParticleOverlay } from "./components/ParticleOverlay";
|
|
||||||
import { DetailPanel } from "./panels/DetailPanel";
|
import { DetailPanel } from "./panels/DetailPanel";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { OffsetEdge } from "./components/OffsetEdge";
|
import { OffsetEdge } from "./components/OffsetEdge";
|
||||||
import { HeaderBar } from "./components/HeaderBar";
|
import { HeaderBar } from "./components/HeaderBar";
|
||||||
import { EdgeLegend } from "./components/EdgeLegend";
|
import { EdgeLegend } from "./components/EdgeLegend";
|
||||||
import type { Service, Flow } from "../shared/types";
|
import type { Service } from "../shared/types";
|
||||||
|
|
||||||
const nodeTypes = { service: ServiceNode, group: GroupNode };
|
const nodeTypes = { service: ServiceNode, group: GroupNode };
|
||||||
const edgeTypes = { offsetSmooth: OffsetEdge };
|
const edgeTypes = { offsetSmooth: OffsetEdge };
|
||||||
@@ -70,13 +68,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard({ token }: { token: string }) {
|
function Dashboard({ token }: { token: string }) {
|
||||||
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn, setProcessing, getLogsSince } = useDocker(token);
|
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = 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);
|
||||||
@@ -370,55 +362,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
// 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);
|
|
||||||
sendMessage({ type: "simulate_flow", flowId: flow.id });
|
|
||||||
}, [resolveFlowPath, engine, sendMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return onParticleSpawn((data) => {
|
|
||||||
const pathUids = resolveFlowPath(data.path);
|
|
||||||
engine.spawn(data.flowId, data.color, data.speed, pathUids);
|
|
||||||
});
|
|
||||||
}, [onParticleSpawn, resolveFlowPath, engine]);
|
|
||||||
|
|
||||||
// Particle node hits
|
|
||||||
const nodeHitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
||||||
const handleNodeHits = useCallback((hits: { nodeId: string; color: string }[]) => {
|
|
||||||
for (const hit of hits) {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Total resource consumption
|
// Total resource consumption
|
||||||
const totalStats = useMemo(() => {
|
const totalStats = useMemo(() => {
|
||||||
let cpu = 0;
|
let cpu = 0;
|
||||||
@@ -466,10 +409,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
hiddenProjects={hiddenProjects}
|
hiddenProjects={hiddenProjects}
|
||||||
onToggleProject={toggleProject}
|
onToggleProject={toggleProject}
|
||||||
totalStats={totalStats}
|
totalStats={totalStats}
|
||||||
flows={flows}
|
|
||||||
flowSettings={flowSettings}
|
|
||||||
engine={engine}
|
|
||||||
onSimulate={handleSimulate}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Canvas — inset */}
|
{/* Canvas — inset */}
|
||||||
@@ -530,7 +469,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
translateExtent={[[-1000, -1000], [8000, 6000]]}
|
translateExtent={[[-1000, -1000], [8000, 6000]]}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
|
|
||||||
<Background color="#374151" gap={30} size={2} />
|
<Background color="#374151" gap={30} size={2} />
|
||||||
<Controls position="bottom-left" />
|
<Controls position="bottom-left" />
|
||||||
<EdgeLegend />
|
<EdgeLegend />
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react";
|
import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react";
|
||||||
import type { Service, Flow, FlowSettings } from "../../shared/types";
|
import type { Service } from "../../shared/types";
|
||||||
import type { ParticleEngine } from "../engine/particles";
|
|
||||||
import { FlowPanel } from "../panels/FlowPanel";
|
|
||||||
|
|
||||||
interface HeaderBarProps {
|
interface HeaderBarProps {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
@@ -13,10 +11,6 @@ interface HeaderBarProps {
|
|||||||
hiddenProjects: Set<string>;
|
hiddenProjects: Set<string>;
|
||||||
onToggleProject: (project: string) => void;
|
onToggleProject: (project: string) => void;
|
||||||
totalStats: { cpu: number; mem: number };
|
totalStats: { cpu: number; mem: number };
|
||||||
flows: Flow[];
|
|
||||||
flowSettings: FlowSettings;
|
|
||||||
engine: ParticleEngine;
|
|
||||||
onSimulate: (flow: Flow) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderBar({
|
export function HeaderBar({
|
||||||
@@ -28,10 +22,6 @@ export function HeaderBar({
|
|||||||
hiddenProjects,
|
hiddenProjects,
|
||||||
onToggleProject,
|
onToggleProject,
|
||||||
totalStats,
|
totalStats,
|
||||||
flows,
|
|
||||||
flowSettings,
|
|
||||||
engine,
|
|
||||||
onSimulate,
|
|
||||||
}: HeaderBarProps) {
|
}: HeaderBarProps) {
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const filterRef = useRef<HTMLDivElement>(null);
|
const filterRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -88,15 +78,6 @@ export function HeaderBar({
|
|||||||
</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={onSimulate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Project filter dropdown */}
|
{/* Project filter dropdown */}
|
||||||
{projects.length > 1 && (
|
{projects.length > 1 && (
|
||||||
<div className="relative" ref={filterRef}>
|
<div className="relative" ref={filterRef}>
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, Flow, FlowSettings, WSMessage } from "../../shared/types";
|
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||||
|
|
||||||
function arraysEqual(a: Service[], b: Service[]): boolean {
|
function arraysEqual(a: Service[], b: Service[]): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
@@ -19,11 +19,6 @@ export function useDocker(token = "") {
|
|||||||
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 [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);
|
||||||
@@ -36,14 +31,9 @@ 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() : []),
|
||||||
fetch("/api/flows", { headers }).then((r) => r.ok ? r.json() : null),
|
]).then(([svcs, conns]) => {
|
||||||
]).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]);
|
||||||
|
|
||||||
@@ -153,13 +143,6 @@ 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 (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse WS message:", err);
|
console.error("Failed to parse WS message:", err);
|
||||||
@@ -202,11 +185,6 @@ export function useDocker(token = "") {
|
|||||||
|
|
||||||
const clearLogLines = useCallback(() => setLogLines([]), []);
|
const clearLogLines = useCallback(() => setLogLines([]), []);
|
||||||
|
|
||||||
const onParticleSpawn = useCallback((cb: (data: { flowId: string; color: string; speed: number; path: string[] }) => void) => {
|
|
||||||
particleSpawnCallbacks.current.add(cb);
|
|
||||||
return () => { particleSpawnCallbacks.current.delete(cb); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actionTimestamps = useRef<Map<string, number>>(new Map());
|
const actionTimestamps = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
const setProcessing = useCallback((uid: string, expectedState: Service["state"], minDuration = 0) => {
|
const setProcessing = useCallback((uid: string, expectedState: Service["state"], minDuration = 0) => {
|
||||||
@@ -220,5 +198,5 @@ export function useDocker(token = "") {
|
|||||||
return actionTimestamps.current.get(uid);
|
return actionTimestamps.current.get(uid);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn, setProcessing, getLogsSince };
|
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ interface ServiceNodeData {
|
|||||||
stats: Stats | null;
|
stats: Stats | null;
|
||||||
flash?: string;
|
flash?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
particleGlow?: string;
|
|
||||||
activeHandles?: string[];
|
activeHandles?: string[];
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -119,7 +118,6 @@ 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.particleGlow || "";
|
|
||||||
const activeHandles = new Set<string>(d.activeHandles || []);
|
const activeHandles = new Set<string>(d.activeHandles || []);
|
||||||
const highlighted = d.highlighted;
|
const highlighted = d.highlighted;
|
||||||
const hdot = (id: string) => {
|
const hdot = (id: string) => {
|
||||||
@@ -138,12 +136,8 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
|||||||
<div
|
<div
|
||||||
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${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 ${s.border} ${s.bg} backdrop-blur-sm
|
className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm
|
||||||
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
|
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring}
|
||||||
transition-all duration-300 ${flashClass}`}
|
transition-all duration-300 ${flashClass}`}
|
||||||
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) => (
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
if (closing) setVisible(false);
|
if (closing) setVisible(false);
|
||||||
}, [closing]);
|
}, [closing]);
|
||||||
|
|
||||||
// Slide-out then unmount
|
// Slide-out + zoom-out in parallel
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setTimeout(() => onClose(), 400);
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Fetch initial logs + subscribe
|
// Fetch initial logs + subscribe
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { Play, Trash2, ChevronDown, Zap, Radio } 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 [demoActive, setDemoActive] = useState(false);
|
|
||||||
const demoRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const demoIndexRef = useRef(0);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Demo mode: simulate a random flow every ~3s
|
|
||||||
const toggleDemo = useCallback(() => {
|
|
||||||
setDemoActive((prev) => {
|
|
||||||
if (prev) {
|
|
||||||
if (demoRef.current) clearInterval(demoRef.current);
|
|
||||||
demoRef.current = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
demoIndexRef.current = 0;
|
|
||||||
demoRef.current = setInterval(() => {
|
|
||||||
if (flows.length === 0) return;
|
|
||||||
const flow = flows[demoIndexRef.current % flows.length];
|
|
||||||
onSimulate(flow);
|
|
||||||
demoIndexRef.current++;
|
|
||||||
}, 3000);
|
|
||||||
// Fire one immediately
|
|
||||||
if (flows.length > 0) {
|
|
||||||
onSimulate(flows[0]);
|
|
||||||
demoIndexRef.current = 1;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [flows, onSimulate]);
|
|
||||||
|
|
||||||
// Cleanup interval on unmount or when flows change
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (demoRef.current) clearInterval(demoRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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={toggleDemo}
|
|
||||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded transition-colors flex-1 ${
|
|
||||||
demoActive
|
|
||||||
? "text-green-400 bg-green-500/15 hover:bg-green-500/25"
|
|
||||||
: "text-slate-400 hover:text-green-400 hover:bg-slate-700/60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Radio size={12} className={demoActive ? "animate-pulse" : ""} />
|
|
||||||
{demoActive ? "Demo ON" : "Demo"}
|
|
||||||
</button>
|
|
||||||
<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={() => {
|
|
||||||
if (demoActive) toggleDemo();
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFlows(): void {
|
|
||||||
const filePath = path.join(process.cwd(), "flows.yaml");
|
|
||||||
const data: Record<string, any> = {};
|
|
||||||
|
|
||||||
if (flows.length > 0) {
|
|
||||||
data.flows = {};
|
|
||||||
for (const f of flows) {
|
|
||||||
data.flows[f.id] = {
|
|
||||||
name: f.name,
|
|
||||||
...(f.description ? { description: f.description } : {}),
|
|
||||||
color: f.color,
|
|
||||||
speed: f.speed,
|
|
||||||
path: f.path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only write settings if they differ from defaults
|
|
||||||
const hasCustomSettings = Object.entries(settings).some(
|
|
||||||
([k, v]) => DEFAULT_SETTINGS[k as keyof FlowSettings] !== v,
|
|
||||||
);
|
|
||||||
if (hasCustomSettings) {
|
|
||||||
data.settings = settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, yaml.stringify(data), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addFlow(flow: Flow): void {
|
|
||||||
if (flows.some((f) => f.id === flow.id)) {
|
|
||||||
throw new Error(`Flow "${flow.id}" already exists`);
|
|
||||||
}
|
|
||||||
flows.push(flow);
|
|
||||||
saveFlows();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateFlow(id: string, partial: Partial<Omit<Flow, "id">>): Flow {
|
|
||||||
const idx = flows.findIndex((f) => f.id === id);
|
|
||||||
if (idx === -1) {
|
|
||||||
throw new Error(`Flow "${id}" not found`);
|
|
||||||
}
|
|
||||||
flows[idx] = { ...flows[idx], ...partial, id };
|
|
||||||
saveFlows();
|
|
||||||
return flows[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteFlow(id: string): void {
|
|
||||||
const idx = flows.findIndex((f) => f.id === id);
|
|
||||||
if (idx === -1) {
|
|
||||||
throw new Error(`Flow "${id}" not found`);
|
|
||||||
}
|
|
||||||
flows.splice(idx, 1);
|
|
||||||
saveFlows();
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||||
import { pollStats, watchDockerEvents } from "./watcher";
|
import { pollStats, watchDockerEvents } from "./watcher";
|
||||||
import { loadFlows, getFlows, getSettings } from "./flows";
|
|
||||||
import type { Service, WSMessage } from "../shared/types";
|
import type { Service, WSMessage } from "../shared/types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -57,13 +56,6 @@ 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() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Container actions ──
|
// ── Container actions ──
|
||||||
app.post("/api/containers/:id/stop", async (c) => {
|
app.post("/api/containers/:id/stop", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
@@ -337,11 +329,6 @@ const server = Bun.serve({
|
|||||||
|
|
||||||
if (!AUTH_TOKEN) {
|
if (!AUTH_TOKEN) {
|
||||||
// No auth required — send data immediately
|
// 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 {}
|
|
||||||
}
|
|
||||||
// Send current services/connections/stats
|
|
||||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
discoverServices(ALL, PROJECTS).then(async (services) => {
|
||||||
const connections = await discoverConnections(services);
|
const connections = await discoverConnections(services);
|
||||||
const stats = await pollStats(services);
|
const stats = await pollStats(services);
|
||||||
@@ -369,11 +356,6 @@ const server = Bun.serve({
|
|||||||
if (msg.token === AUTH_TOKEN) {
|
if (msg.token === AUTH_TOKEN) {
|
||||||
authenticatedClients.add(native);
|
authenticatedClients.add(native);
|
||||||
native.send(JSON.stringify({ type: "auth_ok" }));
|
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 }));
|
|
||||||
}
|
|
||||||
// Send current services/connections/stats immediately
|
// Send current services/connections/stats immediately
|
||||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
discoverServices(ALL, PROJECTS).then(async (services) => {
|
||||||
const connections = await discoverConnections(services);
|
const connections = await discoverConnections(services);
|
||||||
@@ -405,14 +387,6 @@ 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 (err) {
|
} catch (err) {
|
||||||
console.error("Failed to handle WS message:", err);
|
console.error("Failed to handle WS message:", err);
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { spawn, type ChildProcess } from "child_process";
|
|
||||||
import { discoverServices, discoverConnections, getContainerLogs } from "./docker";
|
|
||||||
import { pollStats } from "./watcher";
|
|
||||||
import { loadFlows, getFlows, getSettings, addFlow, updateFlow, deleteFlow } from "./flows";
|
|
||||||
import type { Flow } from "../shared/types";
|
|
||||||
|
|
||||||
// ── Init ──
|
|
||||||
loadFlows();
|
|
||||||
|
|
||||||
// ── Dashboard process state ──
|
|
||||||
let dashboardProc: ChildProcess | null = null;
|
|
||||||
|
|
||||||
const server = new McpServer({
|
|
||||||
name: "dockerflow",
|
|
||||||
version: "0.1.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Flow tools ──
|
|
||||||
|
|
||||||
server.tool("list_flows", "List configured flows and particle settings from flows.yaml", {}, async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify({ flows: getFlows(), settings: getSettings() }, null, 2) }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"create_flow",
|
|
||||||
"Create a new flow in flows.yaml",
|
|
||||||
{
|
|
||||||
id: z.string().describe("Unique flow identifier"),
|
|
||||||
name: z.string().describe("Display name"),
|
|
||||||
color: z.string().describe("Hex color (e.g. #22d3ee)"),
|
|
||||||
speed: z.number().describe("Animation speed multiplier"),
|
|
||||||
path: z.array(z.string()).describe("Ordered list of service names the flow traverses"),
|
|
||||||
description: z.string().optional().describe("Optional description"),
|
|
||||||
},
|
|
||||||
async ({ id, name, color, speed, path, description }) => {
|
|
||||||
try {
|
|
||||||
const flow: Flow = { id, name, color, speed, path, ...(description ? { description } : {}) };
|
|
||||||
addFlow(flow);
|
|
||||||
return { content: [{ type: "text", text: `Flow "${id}" created successfully.` }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"update_flow",
|
|
||||||
"Update an existing flow in flows.yaml",
|
|
||||||
{
|
|
||||||
id: z.string().describe("Flow identifier to update"),
|
|
||||||
name: z.string().optional().describe("New display name"),
|
|
||||||
color: z.string().optional().describe("New hex color"),
|
|
||||||
speed: z.number().optional().describe("New speed multiplier"),
|
|
||||||
path: z.array(z.string()).optional().describe("New path"),
|
|
||||||
description: z.string().optional().describe("New description"),
|
|
||||||
},
|
|
||||||
async ({ id, ...fields }) => {
|
|
||||||
try {
|
|
||||||
const partial: Partial<Omit<Flow, "id">> = {};
|
|
||||||
if (fields.name !== undefined) partial.name = fields.name;
|
|
||||||
if (fields.color !== undefined) partial.color = fields.color;
|
|
||||||
if (fields.speed !== undefined) partial.speed = fields.speed;
|
|
||||||
if (fields.path !== undefined) partial.path = fields.path;
|
|
||||||
if (fields.description !== undefined) partial.description = fields.description;
|
|
||||||
const updated = updateFlow(id, partial);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"delete_flow",
|
|
||||||
"Delete a flow from flows.yaml",
|
|
||||||
{ id: z.string().describe("Flow identifier to delete") },
|
|
||||||
async ({ id }) => {
|
|
||||||
try {
|
|
||||||
deleteFlow(id);
|
|
||||||
return { content: [{ type: "text", text: `Flow "${id}" deleted.` }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"simulate_flow",
|
|
||||||
"Trigger a flow simulation on connected browser clients (requires the web server to be running)",
|
|
||||||
{ flowId: z.string().describe("Flow identifier to simulate") },
|
|
||||||
async ({ flowId }) => {
|
|
||||||
const flow = getFlows().find((f) => f.id === flowId);
|
|
||||||
if (!flow) {
|
|
||||||
return { content: [{ type: "text", text: `Error: Flow "${flowId}" not found.` }], isError: true };
|
|
||||||
}
|
|
||||||
// The MCP server runs as a separate process — it cannot directly broadcast to WebSocket clients.
|
|
||||||
// Return the flow data so the caller knows the simulation details.
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: "text",
|
|
||||||
text: `Flow "${flowId}" found. To trigger the animation, send a WebSocket message to the running DockerFlow server:\n${JSON.stringify({ type: "simulate_flow", flowId }, null, 2)}\n\nFlow details:\n${JSON.stringify(flow, null, 2)}`,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Docker monitoring tools ──
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"list_services",
|
|
||||||
"List Docker services with state, image, ports, and networks",
|
|
||||||
{ project: z.string().optional().describe("Filter by Docker Compose project name") },
|
|
||||||
async ({ project }) => {
|
|
||||||
try {
|
|
||||||
const projects = project ? [project] : [];
|
|
||||||
const all = !project;
|
|
||||||
const services = await discoverServices(all, projects);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(services, null, 2) }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error discovering services: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"get_stats",
|
|
||||||
"Get CPU and memory stats for running Docker services",
|
|
||||||
{ service: z.string().optional().describe("Filter by service uid (project/name)") },
|
|
||||||
async ({ service }) => {
|
|
||||||
try {
|
|
||||||
const services = await discoverServices(true, []);
|
|
||||||
const stats = await pollStats(services);
|
|
||||||
const filtered = service ? stats.filter((s) => s.service === service) : stats;
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error getting stats: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"get_logs",
|
|
||||||
"Get recent log lines from a Docker container",
|
|
||||||
{
|
|
||||||
container_id: z.string().describe("Container ID (short or full)"),
|
|
||||||
tail: z.number().optional().default(50).describe("Number of lines to retrieve (default 50)"),
|
|
||||||
},
|
|
||||||
async ({ container_id, tail }) => {
|
|
||||||
try {
|
|
||||||
const lines = await getContainerLogs(container_id, tail);
|
|
||||||
const text = lines.map((l) => `[${l.stream}] ${l.timestamp} ${l.line}`).join("\n");
|
|
||||||
return { content: [{ type: "text", text: text || "(no logs)" }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error fetching logs: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"get_connections",
|
|
||||||
"Get detected connections between Docker services",
|
|
||||||
{},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const services = await discoverServices(true, []);
|
|
||||||
const connections = await discoverConnections(services);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(connections, null, 2) }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error discovering connections: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Dashboard tools (dev) ──
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"start_dashboard",
|
|
||||||
"Start the DockerFlow dev server (Vite + backend with hot reload). Only for development.",
|
|
||||||
{ mode: z.enum(["dev", "preview"]).optional().default("dev").describe("'dev' = hot reload, 'preview' = build + serve") },
|
|
||||||
async ({ mode }) => {
|
|
||||||
if (dashboardProc && !dashboardProc.killed) {
|
|
||||||
return { content: [{ type: "text", text: "Dashboard is already running. Use stop_dashboard first." }], isError: true };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const args = mode === "preview" ? ["run", "preview"] : ["run", "dev"];
|
|
||||||
dashboardProc = spawn("bun", args, {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: "ignore",
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
const url = mode === "preview" ? "http://localhost:9470" : "http://localhost:5173";
|
|
||||||
return { content: [{ type: "text", text: `Dashboard started in ${mode} mode (PID ${dashboardProc.pid}).\nOpen ${url}` }] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { content: [{ type: "text", text: `Error starting dashboard: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"stop_dashboard",
|
|
||||||
"Stop the running DockerFlow dev server",
|
|
||||||
{},
|
|
||||||
async () => {
|
|
||||||
if (!dashboardProc || dashboardProc.killed) {
|
|
||||||
return { content: [{ type: "text", text: "Dashboard is not running." }], isError: true };
|
|
||||||
}
|
|
||||||
const pid = dashboardProc.pid;
|
|
||||||
dashboardProc.kill();
|
|
||||||
dashboardProc = null;
|
|
||||||
return { content: [{ type: "text", text: `Dashboard stopped (PID ${pid}).` }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Start ──
|
|
||||||
async function main() {
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error("DockerFlow MCP server running on stdio");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Fatal:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
+1
-21
@@ -50,23 +50,6 @@ 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[] }
|
||||||
@@ -74,7 +57,4 @@ 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[] } };
|
|
||||||
|
|||||||
Reference in New Issue
Block a user