mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.3
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user