mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.8
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Database, Zap, Radio, Globe } from "lucide-react";
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ icon: Database, color: "#336791", label: "Database" },
|
||||
{ icon: Zap, color: "#F59E0B", label: "Cache" },
|
||||
{ icon: Radio, color: "#A855F7", label: "Broker" },
|
||||
{ icon: Globe, color: "#22C55E", label: "Proxy" },
|
||||
] as const;
|
||||
|
||||
export function EdgeLegend() {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
|
||||
{LEGEND_ITEMS.map(({ icon: Icon, color, label }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<Icon size={13} style={{ color }} />
|
||||
<span className="text-xs" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react";
|
||||
import type { Service, Flow, FlowSettings } from "../../shared/types";
|
||||
import type { ParticleEngine } from "../engine/particles";
|
||||
import { FlowPanel } from "../panels/FlowPanel";
|
||||
|
||||
interface HeaderBarProps {
|
||||
services: Service[];
|
||||
filteredServices: Service[];
|
||||
connected: boolean;
|
||||
token: string;
|
||||
projects: string[];
|
||||
hiddenProjects: Set<string>;
|
||||
onToggleProject: (project: string) => void;
|
||||
totalStats: { cpu: number; mem: number };
|
||||
flows: Flow[];
|
||||
flowSettings: FlowSettings;
|
||||
engine: ParticleEngine;
|
||||
onSimulate: (flow: Flow) => void;
|
||||
}
|
||||
|
||||
export function HeaderBar({
|
||||
services,
|
||||
filteredServices,
|
||||
connected,
|
||||
token,
|
||||
projects,
|
||||
hiddenProjects,
|
||||
onToggleProject,
|
||||
totalStats,
|
||||
flows,
|
||||
flowSettings,
|
||||
engine,
|
||||
onSimulate,
|
||||
}: HeaderBarProps) {
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const runningCount = filteredServices.filter((s) => s.state === "running").length;
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src="/alteonx-logo.png"
|
||||
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 */}
|
||||
{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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu size={12} className="text-cyan-500" />
|
||||
<span className="text-cyan-400">{totalStats.cpu.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-px h-3 bg-slate-700" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MemoryStick size={12} className="text-violet-500" />
|
||||
<span className="text-violet-400">
|
||||
{totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Flow panel */}
|
||||
<FlowPanel
|
||||
flows={flows}
|
||||
settings={flowSettings}
|
||||
engine={engine}
|
||||
services={services}
|
||||
onSimulate={onSimulate}
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<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) */}
|
||||
{token && (
|
||||
<button
|
||||
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
|
||||
className="text-slate-600 hover:text-slate-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { Lock, Eye, EyeOff, Terminal } from "lucide-react";
|
||||
|
||||
interface LoginScreenProps {
|
||||
onAuth: (token: string) => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({ onAuth }: LoginScreenProps) {
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
|
||||
const hackerLog = (lines: string[], onDone: () => void) => {
|
||||
lines.forEach((line, i) => {
|
||||
setTimeout(() => {
|
||||
setLogLines((prev) => [...prev, line]);
|
||||
if (i === lines.length - 1) setTimeout(onDone, 400);
|
||||
}, i * 180);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (connecting) return;
|
||||
setConnecting(true);
|
||||
setError("");
|
||||
setLogLines([]);
|
||||
|
||||
hackerLog([
|
||||
"$ flowteon connect --auth",
|
||||
"> Establishing secure connection...",
|
||||
"> Validating AUTH_TOKEN...",
|
||||
], async () => {
|
||||
try {
|
||||
const res = await fetch("/api/health", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
hackerLog([
|
||||
"> Token accepted",
|
||||
"> Loading Docker socket...",
|
||||
"> Connection established!",
|
||||
], () => {
|
||||
localStorage.setItem("df:token", token);
|
||||
setConnected(true);
|
||||
setTimeout(() => onAuth(token), 800);
|
||||
});
|
||||
} else {
|
||||
hackerLog(["> ERROR: Invalid token", "> Connection refused"], () => {
|
||||
setError("Token invalido");
|
||||
setConnecting(false);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
hackerLog(["> ERROR: Connection failed"], () => {
|
||||
setError("No se pudo conectar");
|
||||
setConnecting(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen w-screen bg-slate-950 flex items-center justify-center transition-opacity duration-700 ${connected ? "opacity-0" : "opacity-100"}`}>
|
||||
<div className="flex flex-col items-center gap-6 w-80">
|
||||
{/* Logo + Title */}
|
||||
<img
|
||||
src="/alteonx-logo.png"
|
||||
alt="Flowteon"
|
||||
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)" }}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white tracking-wide">Flowteon</h1>
|
||||
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className={`flex flex-col gap-3 w-full transition-opacity duration-300 ${connecting ? "opacity-50 pointer-events-none" : ""}`}>
|
||||
<div className="relative">
|
||||
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={token}
|
||||
onChange={(e) => { setToken(e.target.value); setError(""); }}
|
||||
placeholder="AUTH_TOKEN"
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-sm text-white font-mono placeholder:text-slate-600 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
autoFocus
|
||||
disabled={connecting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="text-red-400 text-xs font-mono">{error}</span>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={connecting || !token}
|
||||
className={`w-full flex items-center justify-center gap-2 text-sm font-medium py-2.5 rounded-lg transition-all duration-300 ${
|
||||
connecting
|
||||
? "bg-slate-800 text-slate-500 cursor-wait"
|
||||
: "bg-cyan-600 hover:bg-cyan-500 text-white hover:shadow-lg hover:shadow-cyan-500/20"
|
||||
}`}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{connecting ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Terminal log */}
|
||||
{logLines.length > 0 && (
|
||||
<div className="w-full bg-slate-900/80 border border-slate-800 rounded-lg p-3 font-mono text-[11px] space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{logLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${
|
||||
line.includes("ERROR") ? "text-red-400" :
|
||||
line.includes("accepted") || line.includes("established") ? "text-emerald-400" :
|
||||
line.startsWith("$") ? "text-cyan-400" : "text-slate-400"
|
||||
} animate-[fadeIn_0.15s_ease-out]`}
|
||||
>
|
||||
{line}
|
||||
{i === logLines.length - 1 && !connected && (
|
||||
<span className="inline-block w-1.5 h-3 bg-cyan-400 ml-1 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SmoothStepEdge, type EdgeProps } from "@xyflow/react";
|
||||
|
||||
export function OffsetEdge(props: EdgeProps) {
|
||||
const offset = (props.data as any)?.offset ?? 0;
|
||||
return <SmoothStepEdge {...props} pathOptions={{ offset, borderRadius: 8 }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user