redesign: complete UI overhaul for 54 nodes and 26 projects
- Replace D3 force graph with categorized grid layout for topology (54 nodes organized by type: infrastructure, servers, PCs, etc.) - Replace individual task cards with project summary cards (progress bars and stage chips instead of 1700+ task cards) - Compact node cards with status-colored backgrounds - Better calendar empty state with centered icon - Refined dark theme with more color depth - Remove D3 dependency (no longer needed) - Fix text sizes for 4K TV readability at distance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,6 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3": "^7.4.3",
|
|
||||||
"d3": "^7.9.0",
|
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import { useWebSocket } from "./hooks/useWebSocket";
|
|||||||
import { useRotation } from "./hooks/useRotation";
|
import { useRotation } from "./hooks/useRotation";
|
||||||
import type { WSMessage } from "./types";
|
import type { WSMessage } from "./types";
|
||||||
|
|
||||||
const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"];
|
const VIEW_NAMES = ["Topología de Red", "Proyectos", "Calendario"];
|
||||||
|
|
||||||
function LoadingScreen({ label }: { label: string }) {
|
function LoadingScreen({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-2xl text-text-secondary animate-pulse">{label}</p>
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-xl text-text-secondary">{label}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,14 +60,14 @@ function App() {
|
|||||||
{topology.data ? (
|
{topology.data ? (
|
||||||
<NetworkGraph nodes={topology.data.nodes} />
|
<NetworkGraph nodes={topology.data.nodes} />
|
||||||
) : (
|
) : (
|
||||||
<LoadingScreen label="Cargando topologia..." />
|
<LoadingScreen label="Cargando topología..." />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{tasks.data ? (
|
{tasks.data ? (
|
||||||
<KanbanBoard projects={tasks.data.projects} />
|
<KanbanBoard projects={tasks.data.projects} />
|
||||||
) : (
|
) : (
|
||||||
<LoadingScreen label="Cargando tareas..." />
|
<LoadingScreen label="Cargando proyectos..." />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|||||||
@@ -24,14 +24,37 @@ export function CalendarView({ events }: CalendarViewProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasEvents = events.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full p-12 overflow-hidden">
|
<div className="flex flex-col h-full">
|
||||||
<section className="mb-10">
|
{/* Summary bar */}
|
||||||
<h2 className="text-3xl font-bold text-text-primary mb-6">
|
<div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
|
||||||
Hoy — {formatDate(today)}
|
<span className="text-lg text-text-secondary">
|
||||||
</h2>
|
<span className="font-bold text-text-primary">{events.length}</span> eventos esta
|
||||||
|
semana
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-16 py-8">
|
||||||
|
{!hasEvents ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-6 -mt-16">
|
||||||
|
<span className="text-8xl opacity-30">📅</span>
|
||||||
|
<p className="text-3xl font-semibold text-text-secondary">
|
||||||
|
Sin eventos programados
|
||||||
|
</p>
|
||||||
|
<p className="text-xl text-text-muted">
|
||||||
|
Los próximos eventos del calendario de Odoo aparecerán aquí
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-10 h-full">
|
||||||
|
{/* Today */}
|
||||||
|
<section className="flex flex-col">
|
||||||
|
<h2 className="text-2xl font-bold text-text-primary mb-2">Hoy</h2>
|
||||||
|
<p className="text-base text-text-muted capitalize mb-6">{formatDate(today)}</p>
|
||||||
{todayEvents.length === 0 ? (
|
{todayEvents.length === 0 ? (
|
||||||
<p className="text-xl text-text-secondary">Sin eventos programados</p>
|
<p className="text-lg text-text-secondary">Sin eventos</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{todayEvents.map((e) => (
|
{todayEvents.map((e) => (
|
||||||
@@ -41,12 +64,14 @@ export function CalendarView({ events }: CalendarViewProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-10">
|
{/* Tomorrow */}
|
||||||
<h2 className="text-2xl font-bold text-text-primary mb-4">
|
<section className="flex flex-col">
|
||||||
Mañana — {formatDate(tomorrow)}
|
<h2 className="text-2xl font-bold text-text-primary mb-2">Mañana</h2>
|
||||||
</h2>
|
<p className="text-base text-text-muted capitalize mb-6">
|
||||||
|
{formatDate(tomorrow)}
|
||||||
|
</p>
|
||||||
{tomorrowEvents.length === 0 ? (
|
{tomorrowEvents.length === 0 ? (
|
||||||
<p className="text-lg text-text-secondary">Sin eventos programados</p>
|
<p className="text-lg text-text-secondary">Sin eventos</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{tomorrowEvents.map((e) => (
|
{tomorrowEvents.map((e) => (
|
||||||
@@ -56,21 +81,31 @@ export function CalendarView({ events }: CalendarViewProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{laterEvents.length > 0 && (
|
{/* This week */}
|
||||||
<section>
|
<section className="flex flex-col">
|
||||||
<h2 className="text-2xl font-bold text-text-primary mb-4">Esta semana</h2>
|
<h2 className="text-2xl font-bold text-text-primary mb-2">Esta semana</h2>
|
||||||
|
<p className="text-base text-text-muted mb-6">Próximos días</p>
|
||||||
|
{laterEvents.length === 0 ? (
|
||||||
|
<p className="text-lg text-text-secondary">Sin eventos</p>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{laterEvents.map((e) => (
|
{laterEvents.map((e) => (
|
||||||
<div key={e.id} className="flex gap-4 text-lg text-text-secondary">
|
<div
|
||||||
<span className="font-mono min-w-[200px] capitalize">
|
key={e.id}
|
||||||
|
className="flex gap-4 text-base bg-bg-card rounded-lg px-4 py-3 border border-border"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-text-muted min-w-[160px] capitalize">
|
||||||
{formatDate(e.start)}
|
{formatDate(e.start)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-text-primary">{e.name}</span>
|
<span className="text-text-primary truncate">{e.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,18 +27,25 @@ export function Header({ viewName, connected }: HeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center justify-between px-12 py-6 bg-bg-secondary border-b border-border">
|
<header className="flex items-center justify-between px-16 py-5 bg-bg-secondary border-b border-border">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-text-primary">
|
||||||
<span className="text-lg text-text-secondary">{viewName}</span>
|
Consultoria AS
|
||||||
|
</h1>
|
||||||
|
<span className="text-xl text-text-muted">|</span>
|
||||||
|
<span className="text-xl font-medium text-accent">{viewName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-10">
|
||||||
<span className="text-lg text-text-secondary capitalize">{dateStr}</span>
|
<span className="text-lg text-text-secondary capitalize">{dateStr}</span>
|
||||||
<span className="text-2xl font-mono font-bold">{timeStr}</span>
|
<span className="text-3xl font-mono font-bold tabular-nums">{timeStr}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-4 h-4 rounded-full ${connected ? "bg-success" : "bg-danger"}`}
|
className={`w-3 h-3 rounded-full ${connected ? "bg-success animate-pulse" : "bg-danger"}`}
|
||||||
title={connected ? "Conectado" : "Desconectado"}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{connected ? "Conectado" : "Sin conexión"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,58 +1,106 @@
|
|||||||
import { TaskCard } from "./TaskCard";
|
|
||||||
import type { Project } from "../../types";
|
import type { Project } from "../../types";
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({ projects }: KanbanBoardProps) {
|
const STAGE_COLORS = [
|
||||||
const allStages = new Set<string>();
|
"bg-blue-500",
|
||||||
for (const project of projects) {
|
"bg-emerald-500",
|
||||||
for (const stage of Object.keys(project.stages)) {
|
"bg-amber-500",
|
||||||
allStages.add(stage);
|
"bg-purple-500",
|
||||||
}
|
"bg-rose-500",
|
||||||
}
|
"bg-cyan-500",
|
||||||
const stageList = Array.from(allStages);
|
"bg-orange-500",
|
||||||
|
"bg-teal-500",
|
||||||
|
];
|
||||||
|
|
||||||
|
function ProjectRow({ project }: { project: Project }) {
|
||||||
|
const stages = Object.entries(project.stages);
|
||||||
|
const totalTasks = stages.reduce((sum, [, tasks]) => sum + tasks.length, 0);
|
||||||
|
|
||||||
|
if (totalTasks === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full p-8 overflow-hidden">
|
<div className="bg-bg-card border border-border rounded-xl p-5 flex flex-col gap-3">
|
||||||
<div
|
{/* Project header */}
|
||||||
className="grid gap-4 mb-6"
|
<div className="flex items-center justify-between">
|
||||||
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}
|
<h3 className="text-lg font-bold text-text-primary truncate pr-4">
|
||||||
>
|
{project.name}
|
||||||
<div className="text-lg font-bold text-text-secondary uppercase tracking-wider">
|
</h3>
|
||||||
Proyecto
|
<span className="text-sm font-mono text-text-muted shrink-0">
|
||||||
</div>
|
{totalTasks} tareas
|
||||||
{stageList.map((stage) => (
|
</span>
|
||||||
<div
|
|
||||||
key={stage}
|
|
||||||
className="text-lg font-bold text-text-secondary uppercase tracking-wider text-center"
|
|
||||||
>
|
|
||||||
{stage}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto space-y-4">
|
{/* Progress bar */}
|
||||||
{projects.map((project) => (
|
<div className="flex h-3 rounded-full overflow-hidden bg-bg-primary">
|
||||||
|
{stages.map(([stageName, tasks], i) => {
|
||||||
|
const pct = (tasks.length / totalTasks) * 100;
|
||||||
|
if (pct < 1) return null;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={stageName}
|
||||||
className="grid gap-4 bg-bg-secondary rounded-xl p-4 border border-border"
|
className={`${STAGE_COLORS[i % STAGE_COLORS.length]} transition-all`}
|
||||||
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}
|
style={{ width: `${pct}%` }}
|
||||||
|
title={`${stageName}: ${tasks.length}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stage chips */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stages.map(([stageName, tasks], i) => (
|
||||||
|
<span
|
||||||
|
key={stageName}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-text-secondary bg-bg-secondary rounded-md px-2.5 py-1"
|
||||||
>
|
>
|
||||||
<div className="flex items-start">
|
<span
|
||||||
<span className="text-xl font-bold text-text-primary">{project.name}</span>
|
className={`w-2 h-2 rounded-full ${STAGE_COLORS[i % STAGE_COLORS.length]}`}
|
||||||
</div>
|
/>
|
||||||
{stageList.map((stage) => (
|
<span className="truncate max-w-[180px]">{stageName}</span>
|
||||||
<div key={stage} className="space-y-2 min-h-[80px]">
|
<span className="font-mono font-bold text-text-primary">{tasks.length}</span>
|
||||||
{(project.stages[stage] || []).map((task) => (
|
</span>
|
||||||
<TaskCard key={task.id} task={task} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function KanbanBoard({ projects }: KanbanBoardProps) {
|
||||||
|
const activeProjects = projects.filter((p) => {
|
||||||
|
const total = Object.values(p.stages).reduce((s, t) => s + t.length, 0);
|
||||||
|
return total > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalTasks = activeProjects.reduce(
|
||||||
|
(sum, p) => sum + Object.values(p.stages).reduce((s, t) => s + t.length, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
|
||||||
|
<span className="text-lg text-text-secondary">
|
||||||
|
<span className="font-bold text-text-primary">{activeProjects.length}</span> proyectos
|
||||||
|
activos
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">|</span>
|
||||||
|
<span className="text-lg text-text-secondary">
|
||||||
|
<span className="font-bold text-text-primary">{totalTasks}</span> tareas totales
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-16 py-6">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{activeProjects.map((project) => (
|
||||||
|
<ProjectRow key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import * as d3 from "d3";
|
|
||||||
import { NodeCard } from "./NodeCard";
|
import { NodeCard } from "./NodeCard";
|
||||||
import type { NetworkNode } from "../../types";
|
import type { NetworkNode } from "../../types";
|
||||||
|
|
||||||
@@ -7,105 +5,86 @@ interface NetworkGraphProps {
|
|||||||
nodes: NetworkNode[];
|
nodes: NetworkNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimNode extends d3.SimulationNodeDatum {
|
const CATEGORY_ORDER: [string, string[]][] = [
|
||||||
id: string;
|
["Infraestructura", ["firewall", "router", "switch", "ap"]],
|
||||||
data: NetworkNode;
|
["Servidores", ["server"]],
|
||||||
}
|
["Almacenamiento", ["nas"]],
|
||||||
|
["Equipos", ["pc"]],
|
||||||
|
["Periféricos", ["printer", "phone", "camera"]],
|
||||||
|
["Otros", ["device"]],
|
||||||
|
];
|
||||||
|
|
||||||
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
|
function categorizeNodes(nodes: NetworkNode[]) {
|
||||||
source: SimNode;
|
const categorized: { label: string; nodes: NetworkNode[] }[] = [];
|
||||||
target: SimNode;
|
const assigned = new Set<string>();
|
||||||
|
|
||||||
|
for (const [label, icons] of CATEGORY_ORDER) {
|
||||||
|
const matching = nodes.filter(
|
||||||
|
(n) => icons.includes(n.icon) && !assigned.has(n.ip)
|
||||||
|
);
|
||||||
|
if (matching.length > 0) {
|
||||||
|
categorized.push({ label, nodes: matching });
|
||||||
|
for (const n of matching) assigned.add(n.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining nodes
|
||||||
|
const remaining = nodes.filter((n) => !assigned.has(n.ip));
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
categorized.push({ label: "Otros", nodes: remaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
return categorized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkGraph({ nodes }: NetworkGraphProps) {
|
export function NetworkGraph({ nodes }: NetworkGraphProps) {
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const categories = categorizeNodes(nodes);
|
||||||
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!svgRef.current || nodes.length === 0) return;
|
|
||||||
|
|
||||||
const width = 3840;
|
|
||||||
const height = 1900;
|
|
||||||
|
|
||||||
const simNodes: SimNode[] = nodes.map((n) => ({
|
|
||||||
id: n.ip,
|
|
||||||
data: n,
|
|
||||||
x: width / 2 + (Math.random() - 0.5) * 800,
|
|
||||||
y: height / 2 + (Math.random() - 0.5) * 600,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nodeMap = new Map(simNodes.map((n) => [n.data.name, n]));
|
|
||||||
|
|
||||||
const simLinks: SimLink[] = [];
|
|
||||||
for (const node of nodes) {
|
|
||||||
for (const connName of node.connections || []) {
|
|
||||||
const target = nodeMap.get(connName);
|
|
||||||
const source = nodeMap.get(node.name);
|
|
||||||
if (source && target) {
|
|
||||||
simLinks.push({ source, target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sim = d3
|
|
||||||
.forceSimulation(simNodes)
|
|
||||||
.force(
|
|
||||||
"link",
|
|
||||||
d3.forceLink(simLinks).id((d: d3.SimulationNodeDatum) => (d as SimNode).id).distance(350)
|
|
||||||
)
|
|
||||||
.force("charge", d3.forceManyBody().strength(-2000))
|
|
||||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
|
||||||
.force("collision", d3.forceCollide(150))
|
|
||||||
.on("tick", () => {
|
|
||||||
// Update link positions via D3
|
|
||||||
const svg = d3.select(svgRef.current);
|
|
||||||
svg
|
|
||||||
.selectAll<SVGLineElement, SimLink>("line.link")
|
|
||||||
.data(simLinks)
|
|
||||||
.join("line")
|
|
||||||
.attr("class", "link")
|
|
||||||
.attr("x1", (d) => d.source.x!)
|
|
||||||
.attr("y1", (d) => d.source.y!)
|
|
||||||
.attr("x2", (d) => d.target.x!)
|
|
||||||
.attr("y2", (d) => d.target.y!)
|
|
||||||
.attr("stroke", "#2a2a3e")
|
|
||||||
.attr("stroke-width", 3);
|
|
||||||
|
|
||||||
// Update React state for node positions
|
|
||||||
const newPositions = new Map<string, { x: number; y: number }>();
|
|
||||||
for (const n of simNodes) {
|
|
||||||
newPositions.set(n.id, { x: n.x!, y: n.y! });
|
|
||||||
}
|
|
||||||
setPositions(newPositions);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sim.stop();
|
|
||||||
};
|
|
||||||
}, [nodes]);
|
|
||||||
|
|
||||||
const onlineCount = nodes.filter((n) => n.status === "up").length;
|
const onlineCount = nodes.filter((n) => n.status === "up").length;
|
||||||
const offlineCount = nodes.filter((n) => n.status === "down").length;
|
const total = nodes.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<svg ref={svgRef} width="3840" height="1900" className="flex-1">
|
{/* Summary bar */}
|
||||||
{nodes.map((node) => {
|
<div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
|
||||||
const pos = positions.get(node.ip);
|
<div className="flex items-center gap-3">
|
||||||
if (!pos) return null;
|
<span className="w-3 h-3 rounded-full bg-success" />
|
||||||
return (
|
<span className="text-lg text-text-secondary">
|
||||||
<NodeCard key={node.ip} node={node} x={pos.x} y={pos.y} />
|
<span className="font-bold text-text-primary">{onlineCount}</span> online
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
<div className="flex items-center justify-center gap-8 py-4 bg-bg-secondary border-t border-border text-lg">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="w-4 h-4 rounded-full bg-success" /> {onlineCount} online
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
</div>
|
||||||
<span className="w-4 h-4 rounded-full bg-danger" /> {offlineCount} offline
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-danger" />
|
||||||
|
<span className="text-lg text-text-secondary">
|
||||||
|
<span className="font-bold text-text-primary">{total - onlineCount}</span> offline
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-text-muted">|</span>
|
||||||
|
<span className="text-lg text-text-secondary">
|
||||||
|
<span className="font-bold text-text-primary">{total}</span> dispositivos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-16 py-6 space-y-6">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<section key={cat.label}>
|
||||||
|
<h3 className="text-sm font-bold text-text-muted uppercase tracking-widest mb-3">
|
||||||
|
{cat.label}
|
||||||
|
<span className="ml-2 text-text-muted font-normal">({cat.nodes.length})</span>
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="grid gap-3"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat.nodes.map((node) => (
|
||||||
|
<NodeCard key={node.ip} node={node} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,63 +2,70 @@ import { useState } from "react";
|
|||||||
import type { NetworkNode } from "../../types";
|
import type { NetworkNode } from "../../types";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, string> = {
|
const ICON_MAP: Record<string, string> = {
|
||||||
router: "\uD83C\uDF10",
|
router: "🌐",
|
||||||
firewall: "\uD83D\uDEE1\uFE0F",
|
firewall: "🛡️",
|
||||||
server: "\uD83D\uDDA5\uFE0F",
|
server: "🖥️",
|
||||||
switch: "\uD83D\uDD00",
|
switch: "🔀",
|
||||||
ap: "\uD83D\uDCE1",
|
ap: "📡",
|
||||||
pc: "\uD83D\uDCBB",
|
pc: "💻",
|
||||||
nas: "\uD83D\uDCBE",
|
nas: "💾",
|
||||||
printer: "\uD83D\uDDA8\uFE0F",
|
printer: "🖨️",
|
||||||
phone: "\uD83D\uDCDE",
|
phone: "📞",
|
||||||
camera: "\uD83D\uDCF7",
|
camera: "📷",
|
||||||
device: "\uD83D\uDCF1",
|
device: "📱",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NodeCardProps {
|
interface NodeCardProps {
|
||||||
node: NetworkNode;
|
node: NetworkNode;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeCard({ node, x, y }: NodeCardProps) {
|
export function NodeCard({ node }: NodeCardProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const statusColor =
|
const isUp = node.status === "up";
|
||||||
node.status === "up"
|
const statusBg = isUp ? "bg-success-dim" : "bg-danger-dim";
|
||||||
? "bg-success"
|
const statusBorder = isUp ? "border-success/30" : "border-danger/30";
|
||||||
: node.status === "down"
|
const statusDot = isUp ? "bg-success" : "bg-danger";
|
||||||
? "bg-danger"
|
|
||||||
: "bg-warning";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<foreignObject x={x - 120} y={y - 80} width={240} height={180}>
|
|
||||||
<div
|
<div
|
||||||
className="bg-bg-card border border-border rounded-xl p-4 flex flex-col items-center gap-1 shadow-lg cursor-pointer select-none"
|
className={`relative ${statusBg} border ${statusBorder} rounded-lg px-4 py-3 cursor-pointer transition-all duration-200 hover:bg-bg-card-hover`}
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<span className="text-3xl">{ICON_MAP[node.icon] || "\uD83D\uDCE6"}</span>
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-base font-bold text-text-primary truncate w-full text-center">
|
<span className={`w-2.5 h-2.5 rounded-full ${statusDot} shrink-0`} />
|
||||||
|
<span className="text-xl shrink-0">{ICON_MAP[node.icon] || "📦"}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-base font-semibold text-text-primary truncate leading-tight">
|
||||||
{node.name}
|
{node.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-mono text-text-secondary leading-tight">{node.ip}</p>
|
||||||
|
</div>
|
||||||
|
{node.public_url && (
|
||||||
|
<span className="text-xs text-accent truncate max-w-[180px] shrink-0 hidden 2xl:block">
|
||||||
|
{node.public_url.replace(/^https?:\/\//, "")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-text-secondary font-mono">{node.ip}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (node.username || node.public_url) && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border-light text-sm space-y-1">
|
||||||
{node.username && (
|
{node.username && (
|
||||||
<span className="text-sm text-text-secondary">
|
<p className="text-text-secondary">
|
||||||
{node.username} / {showPassword ? node.password : "\u2022\u2022\u2022\u2022"}
|
<span className="text-text-muted">User:</span> {node.username}
|
||||||
|
{node.password && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<span className="text-text-muted">Pass:</span>{" "}
|
||||||
|
<span className="font-mono text-warning">{node.password}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{node.public_url && (
|
{node.public_url && (
|
||||||
<span className="text-xs text-accent truncate w-full text-center">
|
<p className="text-accent truncate">{node.public_url}</p>
|
||||||
{node.public_url}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className={`w-3 h-3 rounded-full ${statusColor}`} />
|
|
||||||
<span className="text-xs text-text-secondary">
|
|
||||||
{node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</foreignObject>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-bg-primary: #0a0a0f;
|
--color-bg-primary: #0b0d14;
|
||||||
--color-bg-secondary: #12121a;
|
--color-bg-secondary: #111520;
|
||||||
--color-bg-card: #1a1a2e;
|
--color-bg-card: #181d2a;
|
||||||
--color-border: #2a2a3e;
|
--color-bg-card-hover: #1f2638;
|
||||||
--color-text-primary: #e4e4e7;
|
--color-border: #252d3f;
|
||||||
--color-text-secondary: #a1a1aa;
|
--color-border-light: #2e3750;
|
||||||
--color-accent: #3b82f6;
|
--color-text-primary: #e8eaf0;
|
||||||
--color-success: #22c55e;
|
--color-text-secondary: #8892a8;
|
||||||
--color-danger: #ef4444;
|
--color-text-muted: #5c6478;
|
||||||
--color-warning: #f59e0b;
|
--color-accent: #4f8ff7;
|
||||||
|
--color-accent-dim: #2a4a80;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-success-dim: #0d3d2e;
|
||||||
|
--color-danger: #f87171;
|
||||||
|
--color-danger-dim: #3d1515;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-warning-dim: #3d2e0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -20,9 +27,21 @@ html {
|
|||||||
body {
|
body {
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-family: "Inter", system-ui, sans-serif;
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 3840px;
|
width: 3840px;
|
||||||
height: 2160px;
|
height: 2160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Smooth scrollbar for overflow areas */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user