This commit is contained in:
RGJorge
2026-03-22 09:53:08 +00:00
commit 8703f28856
31 changed files with 4543 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Database,
Zap,
Globe,
Server,
Container,
Shield,
Cog,
Timer,
Radar,
MonitorDot,
KeyRound,
FileCode,
Boxes,
Gem,
Coffee,
Bug,
Rabbit,
Mail,
BarChart3,
type LucideIcon,
} from "lucide-react";
import type { Stats } from "../../shared/types";
interface ServiceNodeData {
label: string;
image: string;
state: string;
ports: { host: number; container: number }[];
project: string;
stats: Stats | null;
flash?: string;
[key: string]: unknown;
}
const stateStyles: Record<string, { ring: string; dot: string; bg: string }> = {
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" },
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
};
// Map image/name patterns to Lucide icons and colors
const iconMap: { pattern: string; icon: LucideIcon; color: string }[] = [
{ pattern: "postgres", icon: Database, color: "#336791" },
{ pattern: "mysql", icon: Database, color: "#4479A1" },
{ pattern: "mariadb", icon: Database, color: "#003545" },
{ pattern: "mongo", icon: Database, color: "#47A248" },
{ pattern: "redis", icon: Zap, color: "#DC382D" },
{ pattern: "memcached", icon: Zap, color: "#3B9C60" },
{ pattern: "nginx", icon: Globe, color: "#009639" },
{ pattern: "traefik", icon: Globe, color: "#24A1C1" },
{ pattern: "haproxy", icon: Globe, color: "#2E86C1" },
{ pattern: "caddy", icon: Globe, color: "#1F88E5" },
{ pattern: "node", icon: MonitorDot, color: "#339933" },
{ pattern: "python", icon: FileCode, color: "#3776AB" },
{ pattern: "golang", icon: Boxes, color: "#00ADD8" },
{ pattern: "ruby", icon: Gem, color: "#CC342D" },
{ pattern: "java", icon: Coffee, color: "#ED8B00" },
{ pattern: "rabbitmq", icon: Rabbit, color: "#FF6600" },
{ pattern: "kafka", icon: Mail, color: "#231F20" },
{ pattern: "grafana", icon: BarChart3, color: "#F46800" },
{ pattern: "prometheus", icon: BarChart3, color: "#E6522C" },
{ pattern: "certbot", icon: Shield, color: "#003A70" },
];
// Name-based patterns (for custom-built images)
const nameIconMap: { pattern: string; icon: LucideIcon; color: string }[] = [
{ pattern: "collector", icon: Radar, color: "#f59e0b" },
{ pattern: "celery", icon: Cog, color: "#97C95F" },
{ pattern: "worker", icon: Cog, color: "#97C95F" },
{ pattern: "beat", icon: Timer, color: "#97C95F" },
{ pattern: "auth", icon: KeyRound, color: "#8b5cf6" },
{ pattern: "backend", icon: Server, color: "#3b82f6" },
{ pattern: "frontend", icon: MonitorDot, color: "#06b6d4" },
{ pattern: "api", icon: Server, color: "#3b82f6" },
];
function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } {
const lowerImage = image.toLowerCase();
const lowerName = name.toLowerCase();
// Check image first
for (const { pattern, icon, color } of iconMap) {
if (lowerImage.includes(pattern)) return { Icon: icon, color };
}
// Then check name
for (const { pattern, icon, color } of nameIconMap) {
if (lowerName.includes(pattern)) return { Icon: icon, color };
}
return { Icon: Container, color: "#64748b" };
}
export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
const d = data as unknown as ServiceNodeData;
const s = stateStyles[d.state] || stateStyles.exited;
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
const flashClass = d.flash || "";
const activeHandles = new Set<string>((d as any).activeHandles || []);
const highlighted = (d as any).highlighted;
const hdot = (id: string) => {
if (activeHandles.has(id)) {
return highlighted
? "!bg-cyan-400 !w-2 !h-2 !border-0 !opacity-100"
: "!bg-slate-500 !w-1.5 !h-1.5 !border-0 !opacity-80";
}
return "!bg-transparent !w-1.5 !h-1.5 !border-0 !opacity-0";
};
// 3 slots per side at 25%, 50%, 75%
const offsets = ["25%", "50%", "75%"];
return (
<div
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 ${s.ring}
transition-all duration-500 ${flashClass}
${d.state === "running" ? "node-pulse-running" : ""}`}
>
{/* Top handles — left offset, transform centered horizontally */}
{offsets.map((o, i) => (
<Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`tt${i}`} type="target" position={Position.Top} id={`top-${i}-target`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
))}
{/* Left handles — top offset, transform centered vertically */}
{offsets.map((o, i) => (
<Handle key={`l${i}`} type="source" position={Position.Left} id={`left-${i}`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`lt${i}`} type="target" position={Position.Left} id={`left-${i}-target`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
))}
{/* Right handles — top offset, transform centered */}
{offsets.map((o, i) => (
<Handle key={`r${i}`} type="source" position={Position.Right} id={`right-${i}`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`rt${i}`} type="target" position={Position.Right} id={`right-${i}-target`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
))}
{/* Header: icon + name + status dot */}
<div className="flex items-center gap-2.5 mb-2">
<div
className="flex items-center justify-center w-8 h-8 rounded-lg"
style={{ backgroundColor: `${iconColor}22` }}
>
<Icon size={18} style={{ color: iconColor }} />
</div>
<span className="font-bold text-white text-sm truncate">{d.label}</span>
<div
className={`w-2 h-2 rounded-full shrink-0 ${s.dot}
${d.state === "running" ? "animate-pulse" : ""}`}
/>
<div className="flex-1" />
</div>
{/* Image */}
<div className="text-xs text-slate-500 truncate mb-2 pl-10">{d.image}</div>
{/* Ports */}
{d.ports?.length > 0 && (
<div className="flex gap-1.5 flex-wrap mb-2 pl-10">
{d.ports.map((p) => (
<span
key={`${p.host}:${p.container}`}
className="text-[11px] bg-slate-800/80 text-cyan-400 px-2 py-0.5 rounded font-mono"
>
:{p.host}
</span>
))}
</div>
)}
{/* Stats */}
{d.stats && (
<div className="mt-2 space-y-1.5">
<div className="flex justify-between text-[11px] text-slate-400">
<span>CPU {d.stats.cpu.toFixed(1)}%</span>
<span>MEM {d.stats.mem_mb.toFixed(0)}MB</span>
</div>
<div className="flex gap-1.5">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
style={{ width: `${Math.min(d.stats.cpu, 100)}%` }}
/>
</div>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
style={{ width: `${Math.min(d.stats.mem_percent, 100)}%` }}
/>
</div>
</div>
</div>
)}
{/* Bottom handles — left offset, transform centered */}
{offsets.map((o, i) => (
<Handle key={`b${i}`} type="source" position={Position.Bottom} id={`bottom-${i}`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`bt${i}`} type="target" position={Position.Bottom} id={`bottom-${i}-target`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
))}
</div>
);
});