"use client"; import { formatBytesCompact, formatUptimeShort } from "@/lib/client/format"; import { hasAnyValue, seriesStats } from "@/lib/client/metrics-history"; import { calculateDockerTotals, containerToProcess, findAppForContainer, getStatusBadgeColor, } from "@/lib/client/monitor-utils"; import { useInstalledApps } from "@/modules/apps/hooks/useInstalledApps"; import { useStoreCatalog } from "@/modules/apps/hooks/useStoreCatalog"; import type { MetricsHistoryRange, SystemMetricsSnapshot } from "@/lib/shared/contracts/system"; import { METRICS_HISTORY_RANGE_MS, METRICS_HISTORY_SAMPLE_MS, toHistoryPoint, } from "@/lib/shared/metrics-history"; import { useMetricsHistory } from "@/modules/system/hooks/useMetricsHistory"; import { useNetworkOverview } from "@/modules/system/hooks/useNetworkOverview"; import { NetworkInterfaceCard, formatMbps, } from "@/modules/system/components/network-interface-card"; import { MetricHistoryChart, type ChartSeries, } from "@/modules/system/components/metric-history-chart"; import { useDockerStats } from "@/modules/system/hooks/useDockerStats"; import { useSystemMetrics } from "@/modules/system/hooks/useSystemMetrics"; import { DiskMonitorCard, formatMegabytesPerSecond, } from "@/modules/system/components/disk-monitor-card"; import { useDiskMonitor } from "@/modules/system/hooks/useDiskMonitor"; import { Activity, AlertTriangle, ArrowDown, ArrowUp, Container, Cpu, Gauge, Globe, HardDrive, MemoryStick, Network, Router, Search, Thermometer, } from "@/components/icons/platform-icons"; import { PANEL_INSET } from "@/lib/ui/surface-tokens"; import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; type MonitorTab = "processes" | "network" | "disks"; type SortKey = "cpu" | "memory" | "network" | "disk"; const TABS: { id: MonitorTab; label: string }[] = [ { id: "processes", label: "Processes" }, { id: "network", label: "Network" }, { id: "disks", label: "Disks" }, ]; // ── Metric card ─────────────────────────────────────────────────────────────── function MetricCard({ label, icon: Icon, value, sub, color, }: { label: string; icon: React.ComponentType<{ className?: string }>; value: string; sub: string; color: string; }) { return (
{label}
{value}
{sub}
); } // ── History card ────────────────────────────────────────────────────────────── const RANGE_OPTIONS: { id: MetricsHistoryRange; label: string }[] = [ { id: "15m", label: "15m" }, { id: "1h", label: "1h" }, { id: "24h", label: "24h" }, ]; function formatPercent(value: number) { return `${value.toFixed(1)}%`; } function formatCelsius(value: number) { return `${value.toFixed(0)} °C`; } function RangeSwitch({ range, onChange, }: { range: MetricsHistoryRange; onChange: (range: MetricsHistoryRange) => void; }) { return (
{RANGE_OPTIONS.map((option) => ( ))}
); } function HistoryCard({ icon: Icon, title, iconColor, rows, children, className, }: { icon: React.ComponentType<{ className?: string }>; title: string; iconColor: string; rows: { label: string; value: string }[]; children: React.ReactNode; className?: string; }) { return (
{title}
{children}
{rows.map((r) => ( ))}
); } // ── Container avatar ────────────────────────────────────────────────────────── /** The app's icon when the container belongs to an installed app, else its initial. */ function ContainerAvatar({ name, logoUrl }: { name: string; logoUrl: string | null }) { const [failed, setFailed] = useState(false); if (logoUrl && !failed) { return (
{/* eslint-disable-next-line @next/next/no-img-element -- store logos are remote and unsized */} setFailed(true)} />
); } return (
{name[0]?.toUpperCase() ?? "C"}
); } // ── Disk usage card ─────────────────────────────────────────────────────────── function DiskUsageCard({ storage }: { storage: SystemMetricsSnapshot["storage"] }) { return (
Disk Usage
{storage ? ( <>
) : (

Storage data unavailable

)}
); } // ── Info row ────────────────────────────────────────────────────────────────── function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value}
); } // ── Monitor ─────────────────────────────────────────────────────────────────── type MonitorProps = { /** Opens the disk manager, where partitioning and wiping live; the Disks tab only watches. */ onOpenDiskManager?: () => void; }; export function Monitor({ onOpenDiskManager }: MonitorProps = {}) { const [tab, setTab] = useState("processes"); const [query, setQuery] = useState(""); const [sortBy, setSortBy] = useState("cpu"); const [range, setRange] = useState("15m"); const { data: systemMetrics } = useSystemMetrics(); const { stats: dockerStats, daemonAvailable, isConnected: dockerConnected } = useDockerStats(); const dockerTotals = useMemo(() => calculateDockerTotals(dockerStats), [dockerStats]); const livePoint = useMemo( () => systemMetrics ? toHistoryPoint(systemMetrics, Date.parse(systemMetrics.timestamp)) : null, [systemMetrics], ); const history = useMetricsHistory(range, livePoint); // Re-rendered with every live frame, so the time axis keeps sliding. const now = livePoint?.t ?? Date.now(); const rangeMs = METRICS_HISTORY_RANGE_MS[range]; const chartProps = { points: history.points, rangeMs, now, gapMs: Math.max(3 * history.intervalSeconds * 1000, 3 * METRICS_HISTORY_SAMPLE_MS), rangeLabel: range === "15m" ? "15 min" : range === "1h" ? "1 h" : "24 h", // 15 min fills the width from the first sample; 1 h and 24 h keep their // real scale so the three views are told apart. fitToData: range === "15m", formatTime: (t: number) => new Date(t).toLocaleTimeString([], range === "24h" ? { hour: "2-digit", minute: "2-digit" } : { hour: "2-digit", minute: "2-digit", second: "2-digit" }), }; const cpuStats = seriesStats(history.points, "cpuPercent"); const memoryStats = seriesStats(history.points, "memoryPercent"); const temperatureStats = seriesStats(history.points, "temperatureCelsius"); const downloadStats = seriesStats(history.points, "downloadMbps"); const uploadStats = seriesStats(history.points, "uploadMbps"); const diskReadStats = seriesStats(history.points, "diskReadMBps"); const diskWriteStats = seriesStats(history.points, "diskWriteMBps"); const diskMonitor = useDiskMonitor(tab === "disks"); const network = useNetworkOverview(tab === "network"); const interfaces = network.data?.interfaces ?? []; // Up with an address, or carrying the default route: the ones worth a card. // The rest (unplugged ports, OS plumbing) are listed in one line each. // An IPv6 link-local address (fe80::) is on every interface and says nothing. // VPN tunnels such as tailscale0 report their state as unknown, not up, so // anything not known to be down counts when it has an address. const activeInterfaces = interfaces.filter( (iface) => iface.isDefault || (iface.up !== false && (iface.ip4 !== null || (iface.ip6 !== null && !iface.ip6.toLowerCase().startsWith("fe80")))), ); const otherInterfaces = interfaces.filter((iface) => !activeInterfaces.includes(iface)); const defaultInterface = interfaces.find((iface) => iface.isDefault) ?? null; const dnsServers = network.data?.dnsServers ?? []; const liveTemperature = livePoint?.temperatureCelsius ?? null; // A VM or a board without a sensor never reports one: the chart says so. const showTemperature = liveTemperature !== null || hasAnyValue(history.points, "temperatureCelsius"); const cpuSeries: ChartSeries[] = [{ key: "cpuPercent", label: "CPU", className: "text-primary" }]; const memorySeries: ChartSeries[] = [ { key: "memoryPercent", label: "Memory", className: "text-chart-2" }, ]; const temperatureSeries: ChartSeries[] = [ { key: "temperatureCelsius", label: "Temperature", className: "text-status-amber" }, ]; const diskSeries: ChartSeries[] = [ { key: "diskReadMBps", label: "Read", className: "text-chart-4" }, { key: "diskWriteMBps", label: "Write", className: "text-status-amber" }, ]; const networkSeries: ChartSeries[] = [ { key: "downloadMbps", label: "Down", className: "text-status-green" }, { key: "uploadMbps", label: "Up", className: "text-sky-400" }, ]; const orDash = (value: number | null, format: (v: number) => string) => value === null ? "--" : format(value); const dockerProcesses = useMemo(() => dockerStats.map(containerToProcess), [dockerStats]); // Same queries as the desktop's app grid, so the icons come from its cache. const installedApps = useInstalledApps(); const installedCatalog = useStoreCatalog({ installedOnly: true }); const logoByContainer = useMemo(() => { const apps = installedApps.data ?? []; const catalogLogos = new Map((installedCatalog.data?.apps ?? []).map((app) => [app.id, app.logoUrl])); return new Map( dockerStats.map((container) => { const app = findAppForContainer(container.name, apps); return [container.name, app ? (catalogLogos.get(app.id) ?? app.logoUrl ?? null) : null] as const; }), ); }, [dockerStats, installedApps.data, installedCatalog.data]); const filteredProcesses = useMemo(() => { const q = query.trim().toLowerCase(); const filtered = q ? dockerProcesses.filter((p) => p.name.toLowerCase().includes(q)) : dockerProcesses; return [...filtered].sort((a, b) => b[sortBy] - a[sortBy]); }, [dockerProcesses, query, sortBy]); return (
{/* Header */}
{TABS.map((t) => ( ))}
{tab === "processes" && (
setQuery(e.target.value)} placeholder="Search containers…" className="w-full rounded-lg border border-glass-border bg-background/55 py-1.5 pl-8 pr-3 text-xs text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-primary/40" />
)}
{/* ── Processes tab ── */} {tab === "processes" && (
{/* Metric summary */}
c.state === "running").length} running` } color={daemonAvailable === false ? "text-status-amber" : "text-chart-4"} />
{/* Resource charts */}
History
{/* Container table */}
Docker Containers {dockerConnected && daemonAvailable !== false && ( Live )}
{daemonAvailable === false && ( Docker daemon unreachable )} {daemonAvailable !== false && dockerStats.length === 0 && ( No containers running )}
Container Status {(["cpu", "memory", "network", "disk"] as SortKey[]).map((key, i) => ( ))}
{filteredProcesses.length === 0 ? (
{daemonAvailable === false ? "Cannot connect to Docker daemon" : query ? "No containers match your search" : "No containers running"}
) : (
{filteredProcesses.map((p) => (

{p.name}

{p.pid}

{p.status} {p.cpu.toFixed(1)}% {Math.round(p.memory)} MB {p.network.toFixed(1)} MB {p.disk.toFixed(1)} MB
))}
)}
)} {/* ── Disks tab ── */} {tab === "disks" && (
Activity
Disks{diskMonitor.data?.disks.length ? ` · ${diskMonitor.data.disks.length}` : ""} {onOpenDiskManager ? ( ) : null}
{diskMonitor.isLoading ? (

Detecting disks…

) : diskMonitor.error ? (

{diskMonitor.error instanceof Error ? diskMonitor.error.message : "Could not load disks"}

) : !diskMonitor.data || diskMonitor.data.disks.length === 0 ? (

{diskMonitor.data?.unavailableReason ?? "No disks found"}

) : (
{diskMonitor.data.disks.map((disk) => ( ))}
)}
)} {/* ── Network tab ── */} {tab === "network" && (
v.toFixed(2))} sub="Mbps now" color="text-status-green" /> v.toFixed(2))} sub="Mbps now" color="text-sky-400" /> 1 ? `+ ${dnsServers.slice(1).join(", ")}` : dnsServers.length === 1 ? "1 server" : "None found" } color="text-chart-4" />
{network.data?.inContainer ? (

Homeio runs in Docker, so these are the container's interfaces, gateway and DNS, not the host's. Run it with network_mode: host to see the host network.

) : null}
Throughput
Interfaces{activeInterfaces.length > 0 ? ` · ${activeInterfaces.length} active` : ""}
{network.isLoading ? (

Loading interfaces…

) : network.error ? (

{network.error instanceof Error ? network.error.message : "Could not load network interfaces"}

) : activeInterfaces.length === 0 ? (

No active network interface

) : (
{activeInterfaces.map((iface) => ( ))}
)} {otherInterfaces.length > 0 ? (
› {otherInterfaces.length} other interface{otherInterfaces.length > 1 ? "s" : ""} (down or without an address)
{otherInterfaces.map((iface) => (
{iface.iface} {iface.kind === "wireless" ? "Wi-Fi" : iface.kind === "vpn" ? "VPN" : iface.kind === "wired" ? "Wired" : "Other"} {" · "} {iface.up === true ? "up, no address" : iface.up === false ? "down" : "unknown"}
))}
) : null}
Container Network Activity
{daemonAvailable === false ? (

Docker daemon unreachable

) : (
{[ { label: "MB Received", value: (dockerTotals.totalNetworkRx / 1024 ** 2).toFixed(1) }, { label: "MB Sent", value: (dockerTotals.totalNetworkTx / 1024 ** 2).toFixed(1) }, ].map(({ label, value }) => (
{value} {label}
))}
)}
System Info
)}
); }