From 5225c1aeda8341648caee188e92a838d43aba4a5 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sat, 2 May 2026 04:54:20 +0000 Subject: [PATCH] v0.0.15 --- CLAUDE.md | 2 +- LICENSE | 2 +- PUBLICAR.md | 2 +- README.md | 6 +- mejoras.md | 18 +- monitoreo.md | 2 +- package.json | 2 +- src/client/App.tsx | 131 ++++++++++++- src/client/components/HeaderBar.tsx | 271 +++++++++++++++----------- src/client/components/LoginScreen.tsx | 6 +- src/client/hooks/useStatsStore.ts | 1 + src/client/index.css | 5 + src/client/index.html | 2 +- src/client/pages/MonitoringPage.tsx | 88 +++++++++ src/client/pages/SettingsPage.tsx | 69 +++++++ src/server/index.ts | 2 +- 16 files changed, 465 insertions(+), 144 deletions(-) create mode 100644 src/client/pages/MonitoringPage.tsx create mode 100644 src/client/pages/SettingsPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b681cf1..f33d577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Dockerflow (Flowteon) +# Dockerflow (ContainerFlow) ## Nomenclatura UI diff --git a/LICENSE b/LICENSE index f31dbf0..149e81d 100644 --- a/LICENSE +++ b/LICENSE @@ -620,7 +620,7 @@ copy of the Program in return for a fee. Copyright (C) 2026 Jorge Gonzalez D. (RGJorge) - Flowteon - Real-time Docker architecture visualizer + ContainerFlow - Real-time Docker architecture visualizer This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by diff --git a/PUBLICAR.md b/PUBLICAR.md index c501fde..17e4362 100644 --- a/PUBLICAR.md +++ b/PUBLICAR.md @@ -299,7 +299,7 @@ Estado actual del proyecto: **v0.1.0** | ~1,774 lineas de codigo | 0 tests | 0 C - Titulo sugerido: "I built a real-time Docker architecture visualizer with live metrics" - Incluir GIF y link al repo - [ ] **Hacker News**: Show HN post - - Titulo: "Show HN: Flowteon – Real-time Docker architecture visualization" + - Titulo: "Show HN: ContainerFlow – Real-time Docker architecture visualization" - [ ] **Twitter/X**: Thread con GIF y features - [ ] **Dev.to**: Articulo sobre como se construyo - [ ] **Discord**: Servidores de Docker, Bun, React diff --git a/README.md b/README.md index 0405313..67f69ee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Flowteon +# ContainerFlow Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard. @@ -10,8 +10,8 @@ Real-time Docker architecture visualizer. Displays services, connections and met ## Instalacion ```bash -git clone https://github.com/RGJorge/flowteon.git -cd flowteon +git clone https://github.com/RGJorge/containerflow.git +cd containerflow bun install ``` diff --git a/mejoras.md b/mejoras.md index d9f1bff..579a902 100644 --- a/mejoras.md +++ b/mejoras.md @@ -1,4 +1,4 @@ -# Mejoras Frontend - Flowteon +# Mejoras Frontend - ContainerFlow ## Documentacion @xyflow/react 12 @@ -25,11 +25,11 @@ --- -## Ejemplos relevantes para Flowteon +## Ejemplos relevantes para ContainerFlow ### Nodos -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Custom Nodes | https://reactflow.dev/examples/nodes/custom-node | Ya usamos ServiceNode y GroupNode custom | | Node Toolbar | https://reactflow.dev/examples/nodes/node-toolbar | Agregar toolbar con acciones (restart, stop, logs) al seleccionar un container | @@ -40,7 +40,7 @@ ### Edges -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Animating Edges | https://reactflow.dev/examples/edges/animating-edges | Animaciones en edges para indicar actividad | | Custom Edges | https://reactflow.dev/examples/edges/custom-edges | Ya usamos OffsetEdge custom | @@ -52,7 +52,7 @@ ### Interaccion -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Context Menu | https://reactflow.dev/examples/interaction/context-menu | Click derecho en container → restart, stop, logs, inspect | | Contextual Zoom | https://reactflow.dev/examples/interaction/contextual-zoom | Mostrar mas detalle (ports, stats) solo cuando hay zoom suficiente | @@ -62,7 +62,7 @@ ### Layout -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Dagre Tree | https://reactflow.dev/examples/layout/dagre | Ya tenemos dagre en package.json pero no lo usamos — alternativa a nuestro layout manual | | Elkjs Tree | https://reactflow.dev/examples/layout/elkjs | Layout mas potente que dagre, reduce cruces de edges | @@ -71,13 +71,13 @@ ### Subflows y Agrupacion -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Sub Flows | https://reactflow.dev/examples/grouping/sub-flows | Referencia para nuestros GroupNode — grafos anidados | ### Estilos -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Dark Mode | https://reactflow.dev/examples/styling/dark-mode | Toggle dark/light mode | | Tailwind | https://reactflow.dev/examples/styling/tailwind | Ya usamos Tailwind — referencia de estilos | @@ -85,7 +85,7 @@ ### Misc -| Ejemplo | URL | Aplicacion en Flowteon | +| Ejemplo | URL | Aplicacion en ContainerFlow | |---------|-----|------------------------| | Download Image | https://reactflow.dev/examples/misc/download-image | Exportar el diagrama como PNG para documentacion | diff --git a/monitoreo.md b/monitoreo.md index 89b62f6..d524a11 100644 --- a/monitoreo.md +++ b/monitoreo.md @@ -1,4 +1,4 @@ -# Flowteon — Roadmap de Monitoreo +# ContainerFlow — Roadmap de Monitoreo ## Fase 1: Acciones básicas - [ ] Stop / Start / Restart desde el DetailPanel diff --git a/package.json b/package.json index 52376d6..9da573a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "flowteon", + "name": "containerflow", "version": "0.1.0", "license": "AGPL-3.0-or-later", "author": "Jorge Gonzalez D. (RGJorge)", diff --git a/src/client/App.tsx b/src/client/App.tsx index b6f985a..cb07e5f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -20,8 +20,11 @@ import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROU import { DetailPanel } from "./panels/DetailPanel"; import { LoginScreen } from "./components/LoginScreen"; import { OffsetEdge } from "./components/OffsetEdge"; -import { HeaderBar } from "./components/HeaderBar"; +import { HeaderBar, type Page } from "./components/HeaderBar"; import { EdgeLegend } from "./components/EdgeLegend"; +import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react"; +import { MonitoringPage } from "./pages/MonitoringPage"; +import { SettingsPage } from "./pages/SettingsPage"; import type { Service } from "../shared/types"; const nodeTypes = { service: ServiceNode, group: GroupNode }; @@ -75,13 +78,27 @@ function Dashboard({ token }: { token: string }) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); + const [activePage, setActivePage] = useState("dashboard"); const [hiddenProjects, setHiddenProjects] = useState>(loadFilter); + const [filterOpen, setFilterOpen] = useState(false); + const filterRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [detailService, setDetailService] = useState(null); const reactFlowRef = useRef(null); const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null); const isDragging = useRef(false); + // Close filter dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) { + setFilterOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + const NODE_W = NODE_WIDTH; const NODE_H = NODE_HEIGHT; const G_PAD = GROUP_PADDING; @@ -428,16 +445,18 @@ function Dashboard({ token }: { token: string }) { - {/* Canvas — inset */} -
+ {activePage === "monitoring" && } + {activePage === "settings" && } + + {/* Canvas — inset (only visible on dashboard) */} +
{ reactFlowRef.current = instance; }} nodes={dimmedNodes} @@ -509,10 +528,81 @@ function Dashboard({ token }: { token: string }) { if (state === "exited" || state === "dead") return "#ef444480"; return "#f59e0b80"; }} + maskColor="rgba(15, 23, 42, 0.6)" style={{ background: "#0f172a" }} /> + {/* Project filter */} + {projects.length > 1 && ( +
+ + {filterOpen && ( +
+ {/* Select/Deselect all */} + +
+ {projects.map((p) => { + const active = !hiddenProjects.has(p); + const projectServices = services.filter((s) => s.project === p); + const running = projectServices.filter((s) => s.state === "running").length; + const stopped = projectServices.length - running; + return ( + + ); + })} +
+ )} +
+ )} + {detailService && ( s.uid === detailService.uid) || detailService} @@ -530,6 +620,33 @@ function Dashboard({ token }: { token: string }) { /> )}
+ + {/* Footer */} +
+
+
+ {connected ? ( + + ) : ( + + )} + + {connected ? "Live" : "Offline"} + +
+ + {filteredServices.filter((s) => s.state === "running").length} + /{filteredServices.length} containers + +
+ +
+ {services.length} containers + {projects.length} projects +
+ + AlteonX +
); diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx index 7feb133..b48b0c1 100644 --- a/src/client/components/HeaderBar.tsx +++ b/src/client/components/HeaderBar.tsx @@ -1,146 +1,187 @@ import { useEffect, useRef, useState } from "react"; -import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react"; -import type { Service } from "../../shared/types"; +import { + LogOut, Cpu, MemoryStick, + LayoutDashboard, Activity, Settings, Bell, + Play, Square, RotateCcw, +} from "lucide-react"; +import type { Service, DockerEvent } from "../../shared/types"; -interface HeaderBarProps { - services: Service[]; - filteredServices: Service[]; - connected: boolean; - token: string; - projects: string[]; - hiddenProjects: Set; - onToggleProject: (project: string) => void; - totalStats: { cpu: number; mem: number }; +export type Page = "dashboard" | "monitoring" | "settings"; + +interface NavButtonProps { + icon: React.ElementType; + label: string; + active: boolean; + onClick: () => void; } -export function HeaderBar({ - services, - filteredServices, - connected, - token, - projects, - hiddenProjects, - onToggleProject, - totalStats, -}: HeaderBarProps) { - const [filterOpen, setFilterOpen] = useState(false); - const filterRef = useRef(null); +function NavButton({ icon: Icon, label, active, onClick }: NavButtonProps) { + return ( + + ); +} - const runningCount = filteredServices.filter((s) => s.state === "running").length; +function timeAgo(ts: number): string { + const diff = Math.floor((Date.now() / 1000) - ts); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function eventIcon(action: string) { + switch (action) { + case "start": return ; + case "stop": case "die": return ; + case "restart": return ; + default: return ; + } +} + +interface NotificationBellProps { + events: DockerEvent[]; +} + +function NotificationBell({ events }: NotificationBellProps) { + const [open, setOpen] = useState(false); + const [lastSeen, setLastSeen] = useState(events.length); + const ref = useRef(null); + + const unread = events.length - lastSeen; useEffect(() => { const handler = (e: MouseEvent) => { - if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) { - setFilterOpen(false); + if (ref.current && !ref.current.contains(e.target as HTMLElement)) { + setOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); - return ( -
-
-
- Flowteon - - Flowteon - - - AlteonX - -
- - v0.0.1 - + const handleToggle = () => { + if (!open) setLastSeen(events.length); + setOpen((v) => !v); + }; - {/* Total resource usage */} - {totalStats.cpu > 0 && ( -
-
- + const recent = events.slice(-20).reverse(); + + return ( +
+ + {open && ( +
+
+ Recent Events +
+ {recent.length === 0 ? ( +
No events yet
+ ) : ( + recent.map((ev, i) => ( +
+ {eventIcon(ev.action)} +
+ {ev.service} +
+ {timeAgo(ev.time)} +
+ )) + )} +
+ )} +
+ ); +} + +interface HeaderBarProps { + services: Service[]; + filteredServices: Service[]; + token: string; + totalStats: { cpu: number; mem: number }; + activePage: Page; + onPageChange: (page: Page) => void; + events: DockerEvent[]; +} + +export function HeaderBar({ + services, + filteredServices, + token, + totalStats, + activePage, + onPageChange, + events, +}: HeaderBarProps) { + + return ( +
+ {/* Left: Navigation */} + + + {/* Center: Logo */} +
+ ContainerFlow +
+ ContainerFlow + v0.0.1 +
+
+ + {/* Right: contextual controls + notifications + logout */} +
+ {activePage === "dashboard" && ( + <> + {/* Total resource usage */} +
+ {totalStats.cpu.toFixed(1)}% -
-
-
- + - + {totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
-
- )} -
-
- {/* Project filter dropdown */} - {projects.length > 1 && ( -
- - {filterOpen && ( -
- {projects.map((p) => { - const active = !hiddenProjects.has(p); - const count = services.filter((s) => s.project === p).length; - return ( - - ); - })} -
- )} -
+ )} - {/* Stats */} - - {runningCount} - /{filteredServices.length} - containers - - - {/* Connection status */} -
- {connected ? ( - - ) : ( - - )} - - {connected ? "Live" : "Offline"} - -
+ {/* Logout (only if auth is active) */} {token && (