Update: metrics API, favicon, weather fix, middleware fix, system improvements
This commit is contained in:
@@ -1,67 +1,133 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, RefreshCw, Wifi, Activity } from "lucide-react";
|
||||
|
||||
const TABS = ["Моя машина", "Сервисы", "Рига"] as const;
|
||||
const TABS = ["Openclaw", "Сервисы"] as const;
|
||||
type TabName = typeof TABS[number];
|
||||
|
||||
const MOCK_DATA = {
|
||||
"Моя машина": { cpu: 23, ram: { used: 8.2, total: 16 }, disk: { used: 124, total: 512 }, uptime: "3д 14ч" },
|
||||
"Сервисы": { cpu: 41, ram: { used: 6.8, total: 9.7 }, disk: { used: 47, total: 97 }, uptime: "12д 2ч" },
|
||||
"Рига": { cpu: 8, ram: { used: 1.1, total: 2 }, disk: { used: 18, total: 40 }, uptime: "45д 7ч" },
|
||||
const MACHINE_MAP: Record<TabName, string> = {
|
||||
"Openclaw": "openclaw",
|
||||
"Сервисы": "services",
|
||||
};
|
||||
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const range = max - min || 1;
|
||||
const h = 32;
|
||||
const w = 80;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - ((v - min) / range) * h;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
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 GaugeChart({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
const r = 36;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const half = circ / 2;
|
||||
const offset = half - (value / 100) * half;
|
||||
|
||||
return (
|
||||
<svg width={w} height={h} className="opacity-60">
|
||||
<polyline points={pts} fill="none" stroke="rgb(99,102,241)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative w-24 h-14 overflow-hidden">
|
||||
<svg width="96" height="56" className="absolute bottom-0">
|
||||
<path
|
||||
d={`M 8 52 A ${r} ${r} 0 0 1 88 52`}
|
||||
fill="none"
|
||||
stroke="rgb(30,41,59)"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={`M 8 52 A ${r} ${r} 0 0 1 88 52`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${half}`}
|
||||
strokeDashoffset={`${offset}`}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute bottom-0 w-full text-center pb-1">
|
||||
<span className="text-xl font-bold text-white">{value}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, sparkData, color }: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
sparkData?: number[];
|
||||
color: string;
|
||||
}) {
|
||||
function UsageBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) {
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
</div>
|
||||
{sparkData && <Sparkline values={sparkData} />}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="text-slate-300">{detail || `${value}%`}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${value}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-slate-500 mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemPage() {
|
||||
const [activeTab, setActiveTab] = useState<typeof TABS[number]>("Моя машина");
|
||||
const data = MOCK_DATA[activeTab];
|
||||
|
||||
const cpuHistory = Array.from({ length: 12 }, () => Math.floor(Math.random() * 40 + data.cpu - 10));
|
||||
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-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">System</h1>
|
||||
<p className="text-slate-400 text-sm">Мониторинг систем (mock данные)</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">System Monitor</h1>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -81,65 +147,128 @@ export default function SystemPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Cpu}
|
||||
label="CPU"
|
||||
value={`${data.cpu}%`}
|
||||
sub="Загрузка процессора"
|
||||
sparkData={cpuHistory}
|
||||
color="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="RAM"
|
||||
value={`${data.ram.used} GB`}
|
||||
sub={`из ${data.ram.total} GB`}
|
||||
color="text-violet-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={HardDrive}
|
||||
label="Disk"
|
||||
value={`${data.disk.used} GB`}
|
||||
sub={`из ${data.disk.total} GB`}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Uptime"
|
||||
value={data.uptime}
|
||||
sub="Время работы"
|
||||
color="text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
<div className="glass-card p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-slate-300">Использование ресурсов</h3>
|
||||
{[
|
||||
{ label: "CPU", value: data.cpu, color: "bg-blue-500" },
|
||||
{ label: "RAM", value: (data.ram.used / data.ram.total) * 100, color: "bg-violet-500" },
|
||||
{ label: "Disk", value: (data.disk.used / data.disk.total) * 100, color: "bg-emerald-500" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-slate-400">{item.label}</span>
|
||||
<span className="text-slate-300">{item.value.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${item.value}%` }}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="glass-card p-8 text-center">
|
||||
<div className="text-red-400 text-sm mb-2">⚠️ Не удалось получить метрики</div>
|
||||
<div className="text-slate-500 text-xs">Убедитесь что node_exporter запущен на хосте</div>
|
||||
<button onClick={fetchMetrics} className="mt-4 text-indigo-400 text-sm hover:underline">
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Gauges row */}
|
||||
<div className="glass-card p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-4 h-4 text-indigo-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Загрузка</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex gap-8 justify-center animate-pulse">
|
||||
{[1,2,3].map(i => <div key={i} className="w-24 h-20 bg-slate-700/50 rounded" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-8 justify-around">
|
||||
<GaugeChart value={metrics?.cpu ?? 0} label="CPU" color="#6366f1" />
|
||||
<GaugeChart value={metrics?.ram?.percent ?? 0} label="RAM" color="#8b5cf6" />
|
||||
<GaugeChart value={metrics?.disk?.percent ?? 0} label="Disk" color="#10b981" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-600 text-center">
|
||||
⚠️ Данные примерные. Реальный мониторинг будет добавлен позже.
|
||||
</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",
|
||||
},
|
||||
{
|
||||
icon: MemoryStick,
|
||||
label: "RAM",
|
||||
value: loading ? "..." : `${metrics?.ram?.used ?? "—"} GB`,
|
||||
sub: loading ? "" : `из ${metrics?.ram?.total ?? "?"} GB`,
|
||||
color: "text-violet-400",
|
||||
},
|
||||
{
|
||||
icon: HardDrive,
|
||||
label: "Disk",
|
||||
value: loading ? "..." : `${metrics?.disk?.used ?? "—"} GB`,
|
||||
sub: loading ? "" : `из ${metrics?.disk?.total ?? "?"} GB`,
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
label: "Uptime",
|
||||
value: loading ? "..." : (metrics?.uptime ?? "—"),
|
||||
sub: "Время работы",
|
||||
color: "text-amber-400",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.label} className="glass-card p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<item.icon className={`w-4 h-4 ${item.color}`} />
|
||||
<span className="text-sm text-slate-400">{item.label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{item.value}</div>
|
||||
{item.sub && <div className="text-xs text-slate-500 mt-1">{item.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
{!loading && metrics && (
|
||||
<div className="glass-card p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-slate-300">Использование ресурсов</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="glass-card p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wifi className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Сеть (всего)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-1">↓ Получено</div>
|
||||
<div className="text-lg font-semibold text-white">{formatBytes(metrics.network.rxBytes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-1">↑ Отправлено</div>
|
||||
<div className="text-lg font-semibold text-white">{formatBytes(metrics.network.txBytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user