fix: reduce font size and compact all views to fit screen

Base font: clamp(10px, 0.75vw, 16px) — much smaller scaling.
At 1920px viewport = ~14px, at 3840px = 16px (capped).

All components heavily compacted:
- Header: minimal padding, smaller text
- Topology: tight InfraCards, small VmPills, 3-col VM grid
- Kanban: 3-col project grid, compact cards
- Calendar: tighter spacing
- Summary bars: single-line, text-xs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 02:00:35 +00:00
parent c4065f2cce
commit 27907a2e39
5 changed files with 145 additions and 237 deletions

View File

@@ -16,76 +16,50 @@ export function CalendarView({ events }: CalendarViewProps) {
(e) => !e.start.startsWith(today) && !e.start.startsWith(tomorrow) (e) => !e.start.startsWith(today) && !e.start.startsWith(tomorrow)
); );
const formatDate = (dateStr: string) => { const fmt = (d: string) =>
return new Date(dateStr).toLocaleDateString("es-MX", { new Date(d).toLocaleDateString("es-MX", { weekday: "long", month: "long", day: "numeric" });
weekday: "long",
month: "long",
day: "numeric",
});
};
const hasEvents = events.length > 0;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-6 px-8 py-2 bg-bg-secondary border-b border-border shrink-0"> <div className="flex items-center gap-4 px-6 py-1.5 bg-bg-secondary border-b border-border shrink-0 text-xs">
<span className="text-base text-text-secondary"> <span className="text-text-secondary">
<span className="font-bold text-text-primary">{events.length}</span> eventos esta semana <span className="font-bold text-text-primary">{events.length}</span> eventos esta semana
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-8 py-6"> {events.length === 0 ? (
{!hasEvents ? ( <div className="flex flex-col items-center justify-center h-full gap-3">
<div className="flex flex-col items-center justify-center h-full gap-4"> <span className="text-5xl opacity-30">📅</span>
<span className="text-6xl opacity-30">📅</span> <p className="text-xl font-semibold text-text-secondary">Sin eventos programados</p>
<p className="text-2xl font-semibold text-text-secondary"> <p className="text-base text-text-muted">Los eventos de Odoo aparecerán aquí</p>
Sin eventos programados
</p>
<p className="text-lg text-text-muted">
Los próximos eventos del calendario de Odoo aparecerán aquí
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 gap-8 h-full"> <div className="grid grid-cols-3 gap-6 h-full">
<section> <section>
<h2 className="text-xl font-bold text-text-primary mb-1">Hoy</h2> <h2 className="text-lg font-bold text-text-primary mb-1">Hoy</h2>
<p className="text-sm text-text-muted capitalize mb-4">{formatDate(today)}</p> <p className="text-xs text-text-muted capitalize mb-3">{fmt(today)}</p>
{todayEvents.length === 0 ? ( {todayEvents.length === 0
<p className="text-base text-text-secondary">Sin eventos</p> ? <p className="text-sm text-text-secondary">Sin eventos</p>
) : ( : <div className="space-y-2">{todayEvents.map((e) => <EventCard key={e.id} event={e} />)}</div>}
<div className="space-y-3">
{todayEvents.map((e) => <EventCard key={e.id} event={e} />)}
</div>
)}
</section> </section>
<section> <section>
<h2 className="text-xl font-bold text-text-primary mb-1">Mañana</h2> <h2 className="text-lg font-bold text-text-primary mb-1">Mañana</h2>
<p className="text-sm text-text-muted capitalize mb-4">{formatDate(tomorrow)}</p> <p className="text-xs text-text-muted capitalize mb-3">{fmt(tomorrow)}</p>
{tomorrowEvents.length === 0 ? ( {tomorrowEvents.length === 0
<p className="text-base text-text-secondary">Sin eventos</p> ? <p className="text-sm text-text-secondary">Sin eventos</p>
) : ( : <div className="space-y-2">{tomorrowEvents.map((e) => <EventCard key={e.id} event={e} />)}</div>}
<div className="space-y-3">
{tomorrowEvents.map((e) => <EventCard key={e.id} event={e} />)}
</div>
)}
</section> </section>
<section> <section>
<h2 className="text-xl font-bold text-text-primary mb-1">Esta semana</h2> <h2 className="text-lg font-bold text-text-primary mb-1">Esta semana</h2>
<p className="text-sm text-text-muted mb-4">Próximos días</p> <p className="text-xs text-text-muted mb-3">Próximos días</p>
{laterEvents.length === 0 ? ( {laterEvents.length === 0
<p className="text-base text-text-secondary">Sin eventos</p> ? <p className="text-sm text-text-secondary">Sin eventos</p>
) : ( : <div className="space-y-1.5">{laterEvents.map((e) => (
<div className="space-y-2"> <div key={e.id} className="flex gap-2 text-xs bg-bg-card rounded px-2 py-1.5 border border-border">
{laterEvents.map((e) => ( <span className="font-mono text-text-muted capitalize">{fmt(e.start)}</span>
<div key={e.id} className="flex gap-3 text-sm bg-bg-card rounded-lg px-3 py-2 border border-border">
<span className="font-mono text-text-muted min-w-[10rem] capitalize">
{formatDate(e.start)}
</span>
<span className="text-text-primary truncate">{e.name}</span> <span className="text-text-primary truncate">{e.name}</span>
</div> </div>
))} ))}</div>}
</div>
)}
</section> </section>
</div> </div>
)} )}

View File

@@ -27,25 +27,18 @@ export function Header({ viewName, connected }: HeaderProps) {
}); });
return ( return (
<header className="flex items-center justify-between px-8 py-3 bg-bg-secondary border-b border-border shrink-0"> <header className="flex items-center justify-between px-6 py-2 bg-bg-secondary border-b border-border shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-text-primary"> <h1 className="text-xl font-bold tracking-tight text-text-primary">Consultoria AS</h1>
Consultoria AS
</h1>
<span className="text-text-muted">|</span> <span className="text-text-muted">|</span>
<span className="text-lg font-medium text-accent">{viewName}</span> <span className="text-base font-medium text-accent">{viewName}</span>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-4">
<span className="text-base text-text-secondary capitalize">{dateStr}</span> <span className="text-sm text-text-secondary capitalize">{dateStr}</span>
<span className="text-2xl font-mono font-bold tabular-nums">{timeStr}</span> <span className="text-xl font-mono font-bold tabular-nums">{timeStr}</span>
<div className="flex items-center gap-2">
<span <span
className={`w-3 h-3 rounded-full ${connected ? "bg-success animate-pulse" : "bg-danger"}`} className={`w-2.5 h-2.5 rounded-full ${connected ? "bg-success animate-pulse" : "bg-danger"}`}
/> />
<span className="text-xs text-text-secondary">
{connected ? "Conectado" : "Sin conexión"}
</span>
</div>
</div> </div>
</header> </header>
); );

View File

@@ -5,57 +5,37 @@ interface KanbanBoardProps {
} }
const STAGE_COLORS = [ const STAGE_COLORS = [
"bg-blue-500", "bg-blue-500", "bg-emerald-500", "bg-amber-500", "bg-purple-500",
"bg-emerald-500", "bg-rose-500", "bg-cyan-500", "bg-orange-500", "bg-teal-500",
"bg-amber-500",
"bg-purple-500",
"bg-rose-500",
"bg-cyan-500",
"bg-orange-500",
"bg-teal-500",
]; ];
function ProjectRow({ project }: { project: Project }) { function ProjectRow({ project }: { project: Project }) {
const stages = Object.entries(project.stages); const stages = Object.entries(project.stages);
const totalTasks = stages.reduce((sum, [, tasks]) => sum + tasks.length, 0); const totalTasks = stages.reduce((sum, [, tasks]) => sum + tasks.length, 0);
if (totalTasks === 0) return null; if (totalTasks === 0) return null;
return ( return (
<div className="bg-bg-card border border-border rounded-xl p-4 flex flex-col gap-2.5"> <div className="bg-bg-card border border-border rounded-lg p-3 flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-bold text-text-primary leading-snug"> <h3 className="text-sm font-bold text-text-primary leading-tight truncate">
{project.name} {project.name}
</h3> </h3>
<span className="text-sm font-mono text-text-secondary shrink-0"> <span className="text-xs font-mono text-text-muted shrink-0">{totalTasks}</span>
{totalTasks}
</span>
</div> </div>
<div className="flex h-2 rounded-full overflow-hidden bg-bg-primary">
<div className="flex h-3 rounded-full overflow-hidden bg-bg-primary"> {stages.map(([name, tasks], i) => {
{stages.map(([stageName, tasks], i) => {
const pct = (tasks.length / totalTasks) * 100; const pct = (tasks.length / totalTasks) * 100;
if (pct < 1) return null; if (pct < 1) return null;
return ( return (
<div <div key={name} className={STAGE_COLORS[i % STAGE_COLORS.length]} style={{ width: `${pct}%` }} />
key={stageName}
className={`${STAGE_COLORS[i % STAGE_COLORS.length]}`}
style={{ width: `${pct}%` }}
/>
); );
})} })}
</div> </div>
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1.5"> {stages.map(([name, tasks], i) => (
{stages.map(([stageName, tasks], i) => ( <span key={name} className="inline-flex items-center gap-1 text-xs text-text-secondary bg-bg-secondary rounded px-1.5 py-0.5">
<span <span className={`w-1.5 h-1.5 rounded-full ${STAGE_COLORS[i % STAGE_COLORS.length]}`} />
key={stageName} <span className="truncate max-w-[10rem]">{name}</span>
className="inline-flex items-center gap-1.5 text-sm text-text-secondary bg-bg-secondary rounded-md px-2.5 py-1"
>
<span
className={`w-2 h-2 rounded-full shrink-0 ${STAGE_COLORS[i % STAGE_COLORS.length]}`}
/>
<span className="truncate max-w-[12rem]">{stageName}</span>
<span className="font-mono font-bold text-text-primary">{tasks.length}</span> <span className="font-mono font-bold text-text-primary">{tasks.length}</span>
</span> </span>
))} ))}
@@ -65,33 +45,27 @@ function ProjectRow({ project }: { project: Project }) {
} }
export function KanbanBoard({ projects }: KanbanBoardProps) { export function KanbanBoard({ projects }: KanbanBoardProps) {
const activeProjects = projects.filter((p) => { const active = projects.filter((p) =>
const total = Object.values(p.stages).reduce((s, t) => s + t.length, 0); Object.values(p.stages).reduce((s, t) => s + t.length, 0) > 0
return total > 0; );
}); const total = active.reduce(
(sum, p) => sum + Object.values(p.stages).reduce((s, t) => s + t.length, 0), 0
const totalTasks = activeProjects.reduce(
(sum, p) => sum + Object.values(p.stages).reduce((s, t) => s + t.length, 0),
0
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-6 px-8 py-2 bg-bg-secondary border-b border-border shrink-0"> <div className="flex items-center gap-4 px-6 py-1.5 bg-bg-secondary border-b border-border shrink-0 text-xs">
<span className="text-base text-text-secondary"> <span className="text-text-secondary">
<span className="font-bold text-text-primary">{activeProjects.length}</span> proyectos <span className="font-bold text-text-primary">{active.length}</span> proyectos
</span> </span>
<span className="text-border-light">|</span> <span className="text-border-light">|</span>
<span className="text-base text-text-secondary"> <span className="text-text-secondary">
<span className="font-bold text-text-primary">{totalTasks}</span> tareas <span className="font-bold text-text-primary">{total}</span> tareas
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-3">
<div className="flex-1 overflow-y-auto px-8 py-4"> <div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-4"> {active.map((p) => <ProjectRow key={p.id} project={p} />)}
{activeProjects.map((project) => (
<ProjectRow key={project.id} project={project} />
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,171 +11,137 @@ const ICON_MAP: Record<string, string> = {
phone: "📞", camera: "📷", device: "📱", phone: "📞", camera: "📷", device: "📱",
}; };
/* ── Compact VM pill ─────────────────────────────────────── */
function VmPill({ node }: { node: NetworkNode }) { function VmPill({ node }: { node: NetworkNode }) {
const isUp = node.status === "up"; const dot = node.status === "up" ? "bg-success" : "bg-danger";
const dotColor = isUp ? "bg-success" : "bg-danger";
return ( return (
<div className="flex items-center gap-1.5 bg-bg-card border border-border rounded-md px-2 py-1"> <div className="flex items-center gap-1 bg-bg-card/80 border border-border rounded px-1.5 py-0.5">
<span className={`w-2 h-2 rounded-full ${dotColor} shrink-0`} /> <span className={`w-1.5 h-1.5 rounded-full ${dot} shrink-0`} />
<span className="text-sm font-medium text-text-primary truncate">{node.name}</span> <span className="text-xs text-text-primary truncate">{node.name}</span>
</div> </div>
); );
} }
/* ── Infrastructure card ─────────────────────────────────── */
function InfraCard({ node }: { node: NetworkNode }) { function InfraCard({ node }: { node: NetworkNode }) {
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const isUp = node.status === "up"; const isUp = node.status === "up";
const borderColor = isUp ? "border-success/40" : "border-danger/40"; const border = isUp ? "border-success/30" : "border-danger/30";
const bgColor = isUp ? "bg-success-dim" : "bg-danger-dim"; const bg = isUp ? "bg-success-dim" : "bg-danger-dim";
const dotColor = isUp ? "bg-success" : "bg-danger"; const dot = isUp ? "bg-success" : "bg-danger";
return ( return (
<div <div
className={`${bgColor} border ${borderColor} rounded-xl px-4 py-3 cursor-pointer transition-all hover:brightness-110`} className={`${bg} border ${border} rounded-lg px-3 py-2 cursor-pointer hover:brightness-110 transition-all`}
onClick={() => setShowPass((p) => !p)} onClick={() => setShowPass((p) => !p)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">{ICON_MAP[node.icon] || "📦"}</span> <span className="text-lg">{ICON_MAP[node.icon] || "📦"}</span>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<p className="text-lg font-bold text-text-primary leading-tight">{node.name}</p> <p className="text-sm font-bold text-text-primary leading-none">{node.name}</p>
<p className="text-sm font-mono text-text-secondary">{node.ip}</p> <p className="text-xs font-mono text-text-secondary leading-none mt-0.5">{node.ip}</p>
</div> </div>
<span className={`w-3 h-3 rounded-full ${dotColor} shrink-0`} /> <span className={`w-2.5 h-2.5 rounded-full ${dot} shrink-0`} />
</div> </div>
{showPass && node.username && ( {showPass && node.username && (
<p className="text-xs text-text-secondary mt-1"> <p className="text-xs text-text-muted mt-1">
{node.username} {node.username}
{node.password && <span className="font-mono text-warning"> / {node.password}</span>} {node.password && <span className="text-warning font-mono"> / {node.password}</span>}
</p> </p>
)} )}
{showPass && node.public_url && ( {showPass && node.public_url && (
<p className="text-xs text-accent mt-0.5">{node.public_url}</p> <p className="text-xs text-accent mt-0.5 truncate">{node.public_url}</p>
)} )}
</div> </div>
); );
} }
/* ── Proxmox server column ───────────────────────────────── */ function ProxmoxCol({ server, vms }: { server: NetworkNode; vms: NetworkNode[] }) {
function ProxmoxColumn({ server, vms }: { server: NetworkNode; vms: NetworkNode[] }) { const up = vms.filter((v) => v.status === "up").length;
const upCount = vms.filter((v) => v.status === "up").length;
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center flex-1">
<div className="w-px h-3 bg-border-light" />
<InfraCard node={server} /> <InfraCard node={server} />
<div className="w-px h-4 bg-border-light" /> <div className="w-px h-2 bg-border-light" />
<span className="text-xs text-text-muted mb-2"> <span className="text-xs text-text-muted mb-1">{up}/{vms.length}</span>
{upCount}/{vms.length} activos <div className="grid grid-cols-3 gap-1 w-full">
</span> {vms.map((vm) => <VmPill key={vm.ip + vm.name} node={vm} />)}
<div className="grid grid-cols-3 gap-1.5 w-full">
{vms.map((vm) => (
<VmPill key={vm.ip + vm.name} node={vm} />
))}
</div> </div>
</div> </div>
); );
} }
/* ── Vertical line ───────────────────────────────────────── */
function VLine() {
return <div className="w-px h-5 bg-border-light mx-auto" />;
}
/* ── Main topology ───────────────────────────────────────── */
export function NetworkGraph({ nodes }: NetworkGraphProps) { export function NetworkGraph({ nodes }: NetworkGraphProps) {
const findByName = (name: string) => nodes.find((n) => n.name === name); const find = (name: string) => nodes.find((n) => n.name === name);
const modem = findByName("Router Telmex"); const modem = find("Router Telmex");
const firewall = findByName("Firewall OPNsense"); const firewall = find("Firewall OPNsense");
const switchNode = findByName("Switch Cisco"); const sw = find("Switch Cisco");
const proxmoxServers = nodes.filter((n) => n.type === "proxmox"); const pve = nodes.filter((n) => n.type === "proxmox");
const otherDevices = nodes.filter( const vms = new Map<string, NetworkNode[]>();
(n) => for (const n of nodes) {
!n.type && if ((n.type === "vm" || n.type === "ct") && n.parent) {
n.name !== "Router Telmex" && const list = vms.get(n.parent) || [];
n.name !== "Firewall OPNsense" && list.push(n);
n.name !== "Switch Cisco" vms.set(n.parent, list);
}
}
const other = nodes.filter(
(n) => !n.type && !["Router Telmex", "Firewall OPNsense", "Switch Cisco"].includes(n.name)
); );
const vmsByParent = new Map<string, NetworkNode[]>(); const online = nodes.filter((n) => n.status === "up").length;
for (const node of nodes) { const vmTotal = nodes.filter((n) => n.type === "vm" || n.type === "ct").length;
if ((node.type === "vm" || node.type === "ct") && node.parent) {
const list = vmsByParent.get(node.parent) || [];
list.push(node);
vmsByParent.set(node.parent, list);
}
}
const onlineCount = nodes.filter((n) => n.status === "up").length;
const total = nodes.length;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Summary bar */} {/* Stats */}
<div className="flex items-center gap-6 px-8 py-2 bg-bg-secondary border-b border-border"> <div className="flex items-center gap-4 px-6 py-1.5 bg-bg-secondary border-b border-border shrink-0 text-xs">
<div className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-full bg-success" /> <span className="w-2 h-2 rounded-full bg-success" />
<span className="text-base text-text-secondary"> <span className="font-bold text-text-primary">{online}</span>
<span className="font-bold text-text-primary">{onlineCount}</span> online <span className="text-text-secondary">online</span>
</span> </span>
</div> <span className="flex items-center gap-1.5">
<div className="flex items-center gap-2"> <span className="w-2 h-2 rounded-full bg-danger" />
<span className="w-3 h-3 rounded-full bg-danger" /> <span className="font-bold text-text-primary">{nodes.length - online}</span>
<span className="text-base text-text-secondary"> <span className="text-text-secondary">offline</span>
<span className="font-bold text-text-primary">{total - onlineCount}</span> offline
</span> </span>
</div>
<span className="text-border-light">|</span> <span className="text-border-light">|</span>
<span className="text-base text-text-secondary"> <span className="text-text-secondary">
<span className="font-bold text-text-primary">{proxmoxServers.length}</span> Proxmox ·{" "} <span className="font-bold text-text-primary">{pve.length}</span> Proxmox ·{" "}
<span className="font-bold text-text-primary"> <span className="font-bold text-text-primary">{vmTotal}</span> VMs/CTs
{nodes.filter((n) => n.type === "vm" || n.type === "ct").length}
</span>{" "}
VMs/CTs
</span> </span>
</div> </div>
{/* Diagram */} {/* Tree */}
<div className="flex-1 overflow-y-auto flex flex-col items-center px-6 py-4"> <div className="flex-1 flex flex-col items-center justify-start px-4 py-3 overflow-hidden">
{/* Top chain: Modem → Firewall → Switch */} {/* Top chain */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
{modem && <InfraCard node={modem} />} {modem && <InfraCard node={modem} />}
<span className="text-border-light text-xl"></span> <span className="text-border-light text-base"></span>
{firewall && <InfraCard node={firewall} />} {firewall && <InfraCard node={firewall} />}
<span className="text-border-light text-xl"></span> <span className="text-border-light text-base"></span>
{switchNode && <InfraCard node={switchNode} />} {sw && <InfraCard node={sw} />}
</div> </div>
<VLine /> {/* Branch down */}
<div className="w-px h-3 bg-border-light" />
{/* Horizontal branch line */} <div className="h-px bg-border-light" style={{ width: "80%" }} />
<div className="w-3/4 h-px bg-border-light" />
{/* Proxmox columns */} {/* Proxmox columns */}
<div className="flex gap-6 w-full px-4 mt-0"> <div className="flex gap-4 w-full">
{proxmoxServers.map((server) => { {pve.map((s) => (
const vms = vmsByParent.get(server.name) || []; <ProxmoxCol key={s.ip} server={s} vms={vms.get(s.name) || []} />
return ( ))}
<div key={server.ip} className="flex-1 flex flex-col items-center">
<div className="w-px h-4 bg-border-light" />
<ProxmoxColumn server={server} vms={vms} />
</div>
);
})}
</div> </div>
{/* Other devices */} {/* Others */}
{otherDevices.length > 0 && ( {other.length > 0 && (
<div className="mt-4 w-full"> <div className="mt-3 w-full">
<p className="text-xs font-bold text-text-muted uppercase tracking-widest mb-2 text-center"> <p className="text-xs text-text-muted uppercase tracking-wider text-center mb-1.5">
Otros dispositivos Otros dispositivos
</p> </p>
<div className="flex justify-center gap-3 flex-wrap"> <div className="flex justify-center gap-2 flex-wrap">
{otherDevices.map((node) => ( {other.map((n) => <InfraCard key={n.ip} node={n} />)}
<InfraCard key={node.ip} node={node} />
))}
</div> </div>
</div> </div>
)} )}

View File

@@ -21,7 +21,8 @@
} }
html { html {
font-size: clamp(14px, 1.15vw, 24px); /* Scales with viewport: ~14px at 1920w, ~16px at 3840w, min 10px */
font-size: clamp(10px, 0.75vw, 16px);
} }
body { body {
@@ -35,12 +36,12 @@ body {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 4px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-border); background: var(--color-border);
border-radius: 3px; border-radius: 2px;
} }