Files
digital-home-dashboard/src/app/(dashboard)/system/page.tsx
Cosmo 50d4edf9e9
Some checks failed
Build & Deploy Dashboard / deploy (push) Failing after 31s
fix: add force-dynamic to prevent prerender errors
2026-04-16 08:43:46 +00:00

241 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const dynamic = "force-dynamic";
"use client";
import { useState, useEffect, useCallback } from "react";
import { Cpu, MemoryStick, HardDrive, Clock, RefreshCw, Wifi, Activity } from "lucide-react";
const TABS = ["Openclaw", "Сервисы"] as const;
type TabName = typeof TABS[number];
const MACHINE_MAP: Record<TabName, string> = {
"Openclaw": "ocplatform",
"Сервисы": "services",
};
interface Metrics {
cpu: number | null;
load1: number | null;
cpuCount: number;
ram: { used: number; total: number; percent: number } | null;
disk: { used: number; total: number; percent: number } | null;
uptime: string | null;
network: { rxBytes: number | null; txBytes: number | null };
}
function CircularGauge({ value, size = 96, color, label }: { value: number; size?: number; color: string; label: string }) {
const radius = (size - 14) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
return (
<div className="flex flex-col items-center gap-2">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<defs>
<linearGradient id={`gauge-gradient-${label}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.9" />
<stop offset="100%" stopColor={color} stopOpacity="0.4" />
</linearGradient>
</defs>
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="10" />
<circle
cx={size/2} cy={size/2} r={radius}
fill="none"
stroke={`url(#gauge-gradient-${label})`}
strokeWidth="10" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 0.8s ease", filter: `drop-shadow(0 0 8px ${color}60)` }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-white">{value}%</span>
</div>
</div>
<span className="text-xs text-slate-400 font-medium">{label}</span>
</div>
);
}
function UsageBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) {
return (
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-slate-400 font-medium">{label}</span>
<span className="text-white font-semibold">{detail || `${value}%`}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden bg-white/5">
<div
className="h-full rounded-full transition-all duration-700"
style={{ width: `${value}%`, background: color, boxShadow: `0 0 8px ${color}80` }}
/>
</div>
</div>
);
}
export default function SystemPage() {
const [activeTab, setActiveTab] = useState<TabName>("Openclaw");
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchMetrics = useCallback(async () => {
setLoading(true);
setError(false);
try {
const res = await fetch(`/api/metrics?machine=${MACHINE_MAP[activeTab]}`);
if (!res.ok) throw new Error();
const data = await res.json();
setMetrics(data);
setLastUpdated(new Date());
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [activeTab]);
useEffect(() => {
fetchMetrics();
const interval = setInterval(fetchMetrics, 30000);
return () => clearInterval(interval);
}, [fetchMetrics]);
const formatBytes = (bytes: number | null) => {
if (bytes == null) return "—";
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(0)} MB`;
};
return (
<div className="space-y-5 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between pt-1">
<div>
<h1 className="text-2xl font-bold text-white">System Monitor</h1>
<p className="text-slate-500 text-sm mt-0.5">
{lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."}
</p>
</div>
<button
onClick={fetchMetrics}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-white/10 text-slate-300 text-sm transition-all hover:border-white/20 hover:bg-white/5 disabled:opacity-50 bg-white/3"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 rounded-2xl w-fit bg-white/5">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-5 py-2 rounded-xl text-sm font-medium transition-all ${
activeTab === tab
? "text-white shadow-lg shadow-indigo-500/20"
: "text-slate-500 hover:text-slate-300"
}`}
style={activeTab === tab ? { background: "linear-gradient(135deg, #6366f1, #8b5cf6)" } : {}}
>
{tab}
</button>
))}
</div>
{error ? (
<div className="card p-8 text-center">
<div className="text-red-400 text-sm mb-2"> Не удалось получить метрики</div>
<div className="text-slate-600 text-xs">Убедитесь что node_exporter запущен на хосте</div>
<button onClick={fetchMetrics} className="mt-4 text-indigo-400 text-sm hover:text-indigo-300 transition-colors">
Попробовать снова
</button>
</div>
) : (
<>
{/* Circular gauges */}
<div className="card card-violet p-6">
<div className="flex items-center gap-2 mb-6">
<div className="w-7 h-7 rounded-lg bg-violet-500/15 flex items-center justify-center">
<Activity className="w-3.5 h-3.5 text-violet-400" />
</div>
<span className="text-sm font-semibold text-white">Загрузка системы</span>
</div>
{loading ? (
<div className="flex gap-12 justify-center animate-pulse">
{[1,2,3].map(i => <div key={i} className="w-24 h-28 bg-white/5 rounded-full" />)}
</div>
) : (
<div className="flex flex-wrap gap-10 justify-around">
<CircularGauge value={metrics?.cpu ?? 0} label="CPU" color="#6366f1" />
<CircularGauge value={metrics?.ram?.percent ?? 0} label="RAM" color="#8b5cf6" />
<CircularGauge value={metrics?.disk?.percent ?? 0} label="Disk" color="#10b981" />
</div>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ icon: Cpu, label: "CPU", value: loading ? "..." : `${metrics?.cpu ?? "—"}%`, sub: loading ? "" : `Load: ${metrics?.load1?.toFixed(2) ?? "—"} · ${metrics?.cpuCount ?? "?"} cores`, color: "text-indigo-400", accent: "card-blue" },
{ icon: MemoryStick, label: "RAM", value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`, color: "text-violet-400", accent: "card-violet" },
{ icon: HardDrive, label: "Disk", value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`, sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`, color: "text-emerald-400", accent: "card-emerald" },
{ icon: Clock, label: "Uptime", value: loading ? "..." : (metrics?.uptime ?? "—"), sub: "Время работы", color: "text-amber-400", accent: "card-amber" },
].map((item) => (
<div key={item.label} className={`card ${item.accent} p-5`}>
<div className="flex items-center gap-2 mb-3">
<item.icon className={`w-4 h-4 ${item.color}`} />
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{item.label}</span>
</div>
<div className="text-2xl font-bold text-white">{item.value}</div>
{item.sub && <div className="text-xs text-slate-600 mt-1">{item.sub}</div>}
</div>
))}
</div>
{/* Usage bars */}
{!loading && metrics && (
<div className="card p-5 space-y-5">
<h3 className="text-sm font-semibold text-white">Использование ресурсов</h3>
<UsageBar label="CPU" value={metrics.cpu ?? 0} color="#6366f1" detail={`${metrics.cpu ?? "—"}%`} />
{metrics.ram && (
<UsageBar label="RAM" value={metrics.ram.percent} color="#8b5cf6" detail={`${metrics.ram.used} / ${metrics.ram.total} GB`} />
)}
{metrics.disk && (
<UsageBar label="Disk" value={metrics.disk.percent} color="#10b981" detail={`${metrics.disk.used} / ${metrics.disk.total} GB`} />
)}
</div>
)}
{/* Network */}
{!loading && metrics?.network && (
<div className="card card-cyan p-5">
<div className="flex items-center gap-2 mb-4">
<div className="w-7 h-7 rounded-lg bg-cyan-500/15 flex items-center justify-center">
<Wifi className="w-3.5 h-3.5 text-cyan-400" />
</div>
<span className="text-sm font-semibold text-white">Сеть (всего)</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl p-3 border border-white/5" style={{ background: "rgba(6,182,212,0.05)" }}>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider"> Получено</div>
<div className="text-xl font-bold text-white">{formatBytes(metrics.network.rxBytes)}</div>
</div>
<div className="rounded-xl p-3 border border-white/5" style={{ background: "rgba(6,182,212,0.05)" }}>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider"> Отправлено</div>
<div className="text-xl font-bold text-white">{formatBytes(metrics.network.txBytes)}</div>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}