This commit is contained in:
RGJorge
2026-05-02 04:54:20 +00:00
parent 37a81952c6
commit 5225c1aeda
16 changed files with 465 additions and 144 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Dockerflow (Flowteon) # Dockerflow (ContainerFlow)
## Nomenclatura UI ## Nomenclatura UI
+1 -1
View File
@@ -620,7 +620,7 @@ copy of the Program in return for a fee.
Copyright (C) 2026 Jorge Gonzalez D. (RGJorge) 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 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 it under the terms of the GNU Affero General Public License as published by
+1 -1
View File
@@ -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" - Titulo sugerido: "I built a real-time Docker architecture visualizer with live metrics"
- Incluir GIF y link al repo - Incluir GIF y link al repo
- [ ] **Hacker News**: Show HN post - [ ] **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 - [ ] **Twitter/X**: Thread con GIF y features
- [ ] **Dev.to**: Articulo sobre como se construyo - [ ] **Dev.to**: Articulo sobre como se construyo
- [ ] **Discord**: Servidores de Docker, Bun, React - [ ] **Discord**: Servidores de Docker, Bun, React
+3 -3
View File
@@ -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. 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 ## Instalacion
```bash ```bash
git clone https://github.com/RGJorge/flowteon.git git clone https://github.com/RGJorge/containerflow.git
cd flowteon cd containerflow
bun install bun install
``` ```
+9 -9
View File
@@ -1,4 +1,4 @@
# Mejoras Frontend - Flowteon # Mejoras Frontend - ContainerFlow
## Documentacion @xyflow/react 12 ## Documentacion @xyflow/react 12
@@ -25,11 +25,11 @@
--- ---
## Ejemplos relevantes para Flowteon ## Ejemplos relevantes para ContainerFlow
### Nodos ### 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 | | 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 | | 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 ### 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 | | 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 | | Custom Edges | https://reactflow.dev/examples/edges/custom-edges | Ya usamos OffsetEdge custom |
@@ -52,7 +52,7 @@
### Interaccion ### 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 | | 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 | | Contextual Zoom | https://reactflow.dev/examples/interaction/contextual-zoom | Mostrar mas detalle (ports, stats) solo cuando hay zoom suficiente |
@@ -62,7 +62,7 @@
### Layout ### 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 | | 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 | | Elkjs Tree | https://reactflow.dev/examples/layout/elkjs | Layout mas potente que dagre, reduce cruces de edges |
@@ -71,13 +71,13 @@
### Subflows y Agrupacion ### 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 | | Sub Flows | https://reactflow.dev/examples/grouping/sub-flows | Referencia para nuestros GroupNode — grafos anidados |
### Estilos ### Estilos
| Ejemplo | URL | Aplicacion en Flowteon | | Ejemplo | URL | Aplicacion en ContainerFlow |
|---------|-----|------------------------| |---------|-----|------------------------|
| Dark Mode | https://reactflow.dev/examples/styling/dark-mode | Toggle dark/light mode | | 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 | | Tailwind | https://reactflow.dev/examples/styling/tailwind | Ya usamos Tailwind — referencia de estilos |
@@ -85,7 +85,7 @@
### Misc ### 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 | | Download Image | https://reactflow.dev/examples/misc/download-image | Exportar el diagrama como PNG para documentacion |
+1 -1
View File
@@ -1,4 +1,4 @@
# Flowteon — Roadmap de Monitoreo # ContainerFlow — Roadmap de Monitoreo
## Fase 1: Acciones básicas ## Fase 1: Acciones básicas
- [ ] Stop / Start / Restart desde el DetailPanel - [ ] Stop / Start / Restart desde el DetailPanel
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "flowteon", "name": "containerflow",
"version": "0.1.0", "version": "0.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": "Jorge Gonzalez D. (RGJorge)", "author": "Jorge Gonzalez D. (RGJorge)",
+124 -7
View File
@@ -20,8 +20,11 @@ import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROU
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, type Page } from "./components/HeaderBar";
import { EdgeLegend } from "./components/EdgeLegend"; 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"; import type { Service } from "../shared/types";
const nodeTypes = { service: ServiceNode, group: GroupNode }; const nodeTypes = { service: ServiceNode, group: GroupNode };
@@ -75,13 +78,27 @@ function Dashboard({ token }: { token: string }) {
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);
const [activePage, setActivePage] = useState<Page>("dashboard");
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter); const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
const [filterOpen, setFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null); const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [detailService, setDetailService] = useState<Service | null>(null); const [detailService, setDetailService] = useState<Service | null>(null);
const reactFlowRef = useRef<any>(null); const reactFlowRef = useRef<any>(null);
const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null); const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null);
const isDragging = useRef(false); 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_W = NODE_WIDTH;
const NODE_H = NODE_HEIGHT; const NODE_H = NODE_HEIGHT;
const G_PAD = GROUP_PADDING; const G_PAD = GROUP_PADDING;
@@ -428,16 +445,18 @@ function Dashboard({ token }: { token: string }) {
<HeaderBar <HeaderBar
services={services} services={services}
filteredServices={filteredServices} filteredServices={filteredServices}
connected={connected}
token={token} token={token}
projects={projects}
hiddenProjects={hiddenProjects}
onToggleProject={toggleProject}
totalStats={totalStats} totalStats={totalStats}
activePage={activePage}
onPageChange={setActivePage}
events={events}
/> />
{/* Canvas — inset */} {activePage === "monitoring" && <MonitoringPage events={events} />}
<div className="flex-1 min-h-0 relative m-2 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)]"> {activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} />}
{/* Canvas — inset (only visible on dashboard) */}
<div className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<ReactFlow <ReactFlow
onInit={(instance) => { reactFlowRef.current = instance; }} onInit={(instance) => { reactFlowRef.current = instance; }}
nodes={dimmedNodes} nodes={dimmedNodes}
@@ -509,10 +528,81 @@ function Dashboard({ token }: { token: string }) {
if (state === "exited" || state === "dead") return "#ef444480"; if (state === "exited" || state === "dead") return "#ef444480";
return "#f59e0b80"; return "#f59e0b80";
}} }}
maskColor="rgba(15, 23, 42, 0.6)"
style={{ background: "#0f172a" }} style={{ background: "#0f172a" }}
/> />
</ReactFlow> </ReactFlow>
{/* Project filter */}
{projects.length > 1 && (
<div className="absolute top-3 right-3 z-10" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors"
>
Projects
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<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-[220px]">
{/* Select/Deselect all */}
<button
onClick={() => {
const allVisible = hiddenProjects.size === 0;
setHiddenProjects(() => {
const next = allVisible ? new Set(projects) : new Set<string>();
localStorage.setItem("df:filter", JSON.stringify([...next]));
return next;
});
}}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
hiddenProjects.size === 0 ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{hiddenProjects.size === 0 && <Check size={12} className="text-white" />}
</div>
<span className="text-slate-300 font-medium">All</span>
<span className="ml-auto flex items-center gap-1.5 text-xs">
<span className="text-emerald-500/70">{services.filter((s) => s.state === "running").length}</span>
<span className="text-slate-600">/</span>
<span className="text-slate-400">{services.length}</span>
</span>
</button>
<div className="border-t border-slate-700/50 my-1" />
{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 (
<button
key={p}
onClick={() => toggleProject(p)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="ml-auto flex items-center gap-1.5 text-xs">
<span className="text-emerald-500/70">{running}</span>
<span className="text-slate-600">/</span>
<span className="text-slate-400">{projectServices.length}</span>
</span>
</button>
);
})}
</div>
)}
</div>
)}
{detailService && ( {detailService && (
<DetailPanel <DetailPanel
service={filteredServices.find((s) => s.uid === detailService.uid) || detailService} service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
@@ -530,6 +620,33 @@ function Dashboard({ token }: { token: string }) {
/> />
)} )}
</div> </div>
{/* Footer */}
<footer className="flex items-center justify-between px-5 py-1.5 text-xs text-slate-600 font-mono relative z-[9999]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{connected ? (
<Wifi size={12} className="text-emerald-500" />
) : (
<WifiOff size={12} className="text-red-500" />
)}
<span className={connected ? "text-emerald-500" : "text-red-500"}>
{connected ? "Live" : "Offline"}
</span>
</div>
<span>
<span className="text-emerald-400">{filteredServices.filter((s) => s.state === "running").length}</span>
<span>/{filteredServices.length} containers</span>
</span>
</div>
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-4">
<span>{services.length} containers</span>
<span>{projects.length} projects</span>
</div>
<span>AlteonX</span>
</footer>
</div> </div>
</StatsStoreContext.Provider> </StatsStoreContext.Provider>
); );
+156 -115
View File
@@ -1,146 +1,187 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react"; import {
import type { Service } from "../../shared/types"; LogOut, Cpu, MemoryStick,
LayoutDashboard, Activity, Settings, Bell,
Play, Square, RotateCcw,
} from "lucide-react";
import type { Service, DockerEvent } from "../../shared/types";
interface HeaderBarProps { export type Page = "dashboard" | "monitoring" | "settings";
services: Service[];
filteredServices: Service[]; interface NavButtonProps {
connected: boolean; icon: React.ElementType;
token: string; label: string;
projects: string[]; active: boolean;
hiddenProjects: Set<string>; onClick: () => void;
onToggleProject: (project: string) => void;
totalStats: { cpu: number; mem: number };
} }
export function HeaderBar({ function NavButton({ icon: Icon, label, active, onClick }: NavButtonProps) {
services, return (
filteredServices, <button
connected, onClick={onClick}
token, className={`flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg transition-colors ${
projects, active
hiddenProjects, ? "bg-slate-800 text-cyan-400"
onToggleProject, : "text-slate-500 hover:text-slate-300 hover:bg-slate-800/50"
totalStats, }`}
}: HeaderBarProps) { >
const [filterOpen, setFilterOpen] = useState(false); <Icon size={18} />
const filterRef = useRef<HTMLDivElement>(null); <span className="text-[10px] font-medium leading-none">{label}</span>
</button>
);
}
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 <Play size={12} className="text-emerald-400" />;
case "stop": case "die": return <Square size={12} className="text-red-400" />;
case "restart": return <RotateCcw size={12} className="text-amber-400" />;
default: return <Activity size={12} className="text-slate-400" />;
}
}
interface NotificationBellProps {
events: DockerEvent[];
}
function NotificationBell({ events }: NotificationBellProps) {
const [open, setOpen] = useState(false);
const [lastSeen, setLastSeen] = useState(events.length);
const ref = useRef<HTMLDivElement>(null);
const unread = events.length - lastSeen;
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) { if (ref.current && !ref.current.contains(e.target as HTMLElement)) {
setFilterOpen(false); setOpen(false);
} }
}; };
document.addEventListener("mousedown", handler); document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler);
}, []); }, []);
return ( const handleToggle = () => {
<div className="flex items-center justify-between px-5 py-3.5 bg-slate-900/90 backdrop-blur-sm relative z-[9999]"> if (!open) setLastSeen(events.length);
<div className="flex items-center gap-4"> setOpen((v) => !v);
<div className="flex items-center gap-2.5"> };
<img
src="/alteonx-logo.webp"
alt="Flowteon"
className="w-7 h-7"
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<span className="text-base font-bold text-white tracking-wide">
Flowteon
</span>
<span className="text-xs text-cyan-400 tracking-widest uppercase font-semibold">
AlteonX
</span>
</div>
<span className="text-xs text-slate-600 font-mono bg-slate-800 px-2 py-0.5 rounded">
v0.0.1
</span>
{/* Total resource usage */} const recent = events.slice(-20).reverse();
{totalStats.cpu > 0 && (
<div className="flex items-center gap-3 ml-2 text-xs font-mono bg-slate-800/80 border border-slate-700/50 px-3 py-1 rounded-md"> return (
<div className="flex items-center gap-1.5"> <div className="relative" ref={ref}>
<Cpu size={12} className="text-cyan-500" /> <button
onClick={handleToggle}
className="relative flex items-center justify-center text-slate-500 hover:text-slate-300 transition-colors"
title="Notifications"
>
<Bell size={16} />
{unread > 0 && (
<span className="absolute -top-1 -right-1 bg-cyan-500 text-white text-[9px] font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1">
{unread > 99 ? "99+" : unread}
</span>
)}
</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 w-72 max-h-80 overflow-auto z-[9999]">
<div className="px-3 py-2 border-b border-slate-700/60 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Recent Events
</div>
{recent.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-slate-500">No events yet</div>
) : (
recent.map((ev, i) => (
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-700/40 transition-colors">
{eventIcon(ev.action)}
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-300 truncate block">{ev.service}</span>
</div>
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
</div>
))
)}
</div>
)}
</div>
);
}
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 (
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
{/* Left: Navigation */}
<nav className="flex items-center gap-1">
<NavButton icon={LayoutDashboard} label="Dashboard" active={activePage === "dashboard"} onClick={() => onPageChange("dashboard")} />
<NavButton icon={Activity} label="Monitoring" active={activePage === "monitoring"} onClick={() => onPageChange("monitoring")} />
<NavButton icon={Settings} label="Settings" active={activePage === "settings"} onClick={() => onPageChange("settings")} />
</nav>
{/* Center: Logo */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
<img
src="/alteonx-logo.webp"
alt="ContainerFlow"
className="w-7 h-7"
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<div className="flex flex-col items-end">
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
<span className="text-[9px] text-slate-500 font-mono -mt-1">v0.0.1</span>
</div>
</div>
{/* Right: contextual controls + notifications + logout */}
<div className="flex items-center gap-4">
{activePage === "dashboard" && (
<>
{/* Total resource usage */}
<div className="flex items-center gap-3 text-xs font-mono text-slate-400">
<Cpu size={14} className="text-cyan-500" />
<span className="text-cyan-400">{totalStats.cpu.toFixed(1)}%</span> <span className="text-cyan-400">{totalStats.cpu.toFixed(1)}%</span>
</div> <span className="text-slate-600">-</span>
<div className="w-px h-3 bg-slate-700" /> <MemoryStick size={14} className="text-violet-500" />
<div className="flex items-center gap-1.5">
<MemoryStick size={12} className="text-violet-500" />
<span className="text-violet-400"> <span className="text-violet-400">
{totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`} {totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
</span> </span>
</div> </div>
</div>
)}
</div>
<div className="flex items-center gap-5"> </>
{/* Project filter dropdown */}
{projects.length > 1 && (
<div className="relative" ref={filterRef}>
<button
onClick={() => setFilterOpen((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"
>
Projects
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<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-[200px] z-[9999]">
{projects.map((p) => {
const active = !hiddenProjects.has(p);
const count = services.filter((s) => s.project === p).length;
return (
<button
key={p}
onClick={() => onToggleProject(p)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="text-slate-500 ml-auto">{count}</span>
</button>
);
})}
</div>
)}
</div>
)} )}
{/* Stats */} <NotificationBell events={events} />
<span className="text-sm text-slate-500">
<span className="text-emerald-400 font-medium">{runningCount}</span>
<span className="text-slate-600">/{filteredServices.length}</span>
<span className="text-slate-600 ml-1">containers</span>
</span>
{/* Connection status */}
<div className="flex items-center gap-2">
{connected ? (
<Wifi size={15} className="text-emerald-500" />
) : (
<WifiOff size={15} className="text-red-500" />
)}
<span className={`text-xs ${connected ? "text-emerald-500" : "text-red-500"}`}>
{connected ? "Live" : "Offline"}
</span>
</div>
{/* Logout (only if auth is active) */} {/* Logout (only if auth is active) */}
{token && ( {token && (
<button <button
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }} onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
className="text-slate-600 hover:text-slate-400 transition-colors" className="flex items-center justify-center text-slate-600 hover:text-slate-400 transition-colors"
title="Logout" title="Logout"
> >
<LogOut size={16} /> <LogOut size={16} />
+3 -3
View File
@@ -30,7 +30,7 @@ export function LoginScreen({ onAuth }: LoginScreenProps) {
setLogLines([]); setLogLines([]);
hackerLog([ hackerLog([
"$ flowteon connect --auth", "$ containerflow connect --auth",
"> Establishing secure connection...", "> Establishing secure connection...",
"> Validating AUTH_TOKEN...", "> Validating AUTH_TOKEN...",
], async () => { ], async () => {
@@ -69,12 +69,12 @@ export function LoginScreen({ onAuth }: LoginScreenProps) {
{/* Logo + Title */} {/* Logo + Title */}
<img <img
src="/alteonx-logo.webp" src="/alteonx-logo.webp"
alt="Flowteon" alt="ContainerFlow"
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`} className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }} style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/> />
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-bold text-white tracking-wide">Flowteon</h1> <h1 className="text-2xl font-bold text-white tracking-wide">ContainerFlow</h1>
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span> <span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
</div> </div>
+1
View File
@@ -45,6 +45,7 @@ export function createStatsStore(): StatsStore {
} }
} }
if (changed) { if (changed) {
current = new Map(current);
globalVersion++; globalVersion++;
notify(); notify();
} }
+5
View File
@@ -18,6 +18,11 @@
overflow: hidden !important; overflow: hidden !important;
} }
.react-flow__minimap-mask {
stroke: rgba(34, 211, 238, 0.8) !important;
stroke-width: 6 !important;
}
.react-flow__controls { .react-flow__controls {
border: 1px solid #1e293b !important; border: 1px solid #1e293b !important;
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flowteon</title> <title>ContainerFlow</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
</head> </head>
<body class="bg-slate-950 text-white"> <body class="bg-slate-950 text-white">
+88
View File
@@ -0,0 +1,88 @@
import { Activity, Play, Square, RotateCcw, AlertTriangle } from "lucide-react";
import type { DockerEvent } from "../../shared/types";
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 <Play size={14} className="text-emerald-400" />;
case "stop":
case "die":
return <Square size={14} className="text-red-400" />;
case "restart":
return <RotateCcw size={14} className="text-amber-400" />;
default:
return <AlertTriangle size={14} className="text-slate-400" />;
}
}
function actionColor(action: string): string {
switch (action) {
case "start": return "text-emerald-400";
case "stop": case "die": return "text-red-400";
case "restart": return "text-amber-400";
default: return "text-slate-400";
}
}
interface MonitoringPageProps {
events: DockerEvent[];
}
export function MonitoringPage({ events }: MonitoringPageProps) {
const sorted = [...events].reverse();
return (
<div className="flex-1 min-h-0 overflow-auto p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Activity size={24} className="text-cyan-400" />
<div>
<h1 className="text-xl font-bold text-white">Event History</h1>
<p className="text-sm text-slate-500">Docker container events in real-time</p>
</div>
</div>
{/* Events list */}
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
{sorted.length === 0 ? (
<div className="px-6 py-12 text-center text-slate-500">
<Activity size={32} className="mx-auto mb-3 opacity-40" />
<p>No events yet. Events will appear here as containers start, stop, or restart.</p>
</div>
) : (
<div className="divide-y divide-slate-700/40">
{sorted.map((ev, i) => (
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/30 transition-colors">
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
{eventIcon(ev.action)}
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-slate-200 font-medium truncate block">{ev.service}</span>
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
</div>
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
</div>
))}
</div>
)}
</div>
{/* Alert Rules placeholder */}
<div className="mt-8 bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-6 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-slate-600" />
<p className="text-sm text-slate-500 font-medium">Alert Rules</p>
<p className="text-xs text-slate-600 mt-1">Configure alerting rules for container events coming soon</p>
</div>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { Settings, Server, Bell, Info } from "lucide-react";
interface SettingsPageProps {
projects: string[];
servicesCount: number;
}
export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
return (
<div className="flex-1 min-h-0 overflow-auto p-6">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Settings size={24} className="text-cyan-400" />
<div>
<h1 className="text-xl font-bold text-white">Settings</h1>
<p className="text-sm text-slate-500">Application configuration</p>
</div>
</div>
{/* General */}
<section className="bg-slate-800/50 border border-slate-700/60 rounded-xl p-5 mb-4">
<div className="flex items-center gap-2 mb-4">
<Info size={16} className="text-cyan-400" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">General</h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Version</span>
<span className="text-slate-200 font-mono">v0.0.1</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Mode</span>
<span className="text-slate-200 font-mono">Single Host</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Projects</span>
<span className="text-slate-200 font-mono">{projects.length}</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Containers</span>
<span className="text-slate-200 font-mono">{servicesCount}</span>
</div>
</div>
</section>
{/* Docker Hosts */}
<section className="bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-5 mb-4">
<div className="flex items-center gap-2 mb-3">
<Server size={16} className="text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Docker Hosts</h2>
</div>
<p className="text-sm text-slate-500">Multi-host management coming soon</p>
<p className="text-xs text-slate-600 mt-1">Connect to remote Docker daemons and manage multiple hosts from a single dashboard.</p>
</section>
{/* Notifications */}
<section className="bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Bell size={16} className="text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Notifications</h2>
</div>
<p className="text-sm text-slate-500">Webhook & email notifications coming soon</p>
<p className="text-xs text-slate-600 mt-1">Configure Slack, Discord, or email alerts for container events and health checks.</p>
</section>
</div>
</div>
);
}
+1 -1
View File
@@ -425,7 +425,7 @@ const server = Bun.serve({
}); });
const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`; const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`;
console.log(`\n Flowteon`); console.log(`\n ContainerFlow`);
console.log(` → http://${HOST}:${PORT}`); console.log(` → http://${HOST}:${PORT}`);
console.log(` → Mode: ${mode}`); console.log(` → Mode: ${mode}`);
console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`); console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`);