mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.15
This commit is contained in:
+156
-115
@@ -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<string>;
|
||||
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<HTMLDivElement>(null);
|
||||
function NavButton({ icon: Icon, label, active, onClick }: NavButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg transition-colors ${
|
||||
active
|
||||
? "bg-slate-800 text-cyan-400"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<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(() => {
|
||||
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 (
|
||||
<div className="flex items-center justify-between px-5 py-3.5 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.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>
|
||||
const handleToggle = () => {
|
||||
if (!open) setLastSeen(events.length);
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
{/* 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" />
|
||||
const recent = events.slice(-20).reverse();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<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>
|
||||
</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-slate-600">-</span>
|
||||
<MemoryStick size={14} 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">
|
||||
{/* 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>
|
||||
<NotificationBell events={events} />
|
||||
|
||||
{/* 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"
|
||||
className="flex items-center justify-center text-slate-600 hover:text-slate-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LoginScreen({ onAuth }: LoginScreenProps) {
|
||||
setLogLines([]);
|
||||
|
||||
hackerLog([
|
||||
"$ flowteon connect --auth",
|
||||
"$ containerflow connect --auth",
|
||||
"> Establishing secure connection...",
|
||||
"> Validating AUTH_TOKEN...",
|
||||
], async () => {
|
||||
@@ -69,12 +69,12 @@ export function LoginScreen({ onAuth }: LoginScreenProps) {
|
||||
{/* Logo + Title */}
|
||||
<img
|
||||
src="/alteonx-logo.webp"
|
||||
alt="Flowteon"
|
||||
alt="ContainerFlow"
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-white tracking-wide">ContainerFlow</h1>
|
||||
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user