This commit is contained in:
RGJorge
2026-03-22 11:05:07 +00:00
parent cd7786ef66
commit 682f4bb910
10 changed files with 629 additions and 17 deletions
-11
View File
@@ -33,17 +33,6 @@
background-color: #334155 !important;
}
/* Pulse animation for running nodes */
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
.node-pulse-running {
animation: pulse-ring 2s infinite;
}
/* Flash animations for Docker events */
@keyframes flash-green {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
+1 -2
View File
@@ -121,8 +121,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
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
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
transition-all duration-300 ${flashClass}
${d.state === "running" ? "node-pulse-running" : ""}`}
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,
+48 -2
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { Play, Trash2, ChevronDown, Zap } from "lucide-react";
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";
@@ -15,8 +15,42 @@ interface FlowPanelProps {
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(() => {
@@ -84,6 +118,17 @@ export function FlowPanel({ flows, settings, engine, services, onSimulate }: Flo
))}
<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);
@@ -95,6 +140,7 @@ export function FlowPanel({ flows, settings, engine, services, onSimulate }: Flo
</button>
<button
onClick={() => {
if (demoActive) toggleDemo();
engine.clear();
setParticleCount(0);
}}
+55
View File
@@ -51,3 +51,58 @@ export function getFlows(): Flow[] {
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();
}
+229
View File
@@ -0,0 +1,229 @@
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);
});