Update: metrics API, favicon, weather fix, middleware fix, system improvements
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.git
|
|
||||||
*.md
|
*.md
|
||||||
.env
|
.env*
|
||||||
.env.local
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|||||||
10
public/favicon.svg
Normal file
10
public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#6366f1"/>
|
||||||
|
<stop offset="100%" style="stop-color:#8b5cf6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#g)"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" font-size="18" font-family="sans-serif" fill="white">⌂</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -1,41 +1,62 @@
|
|||||||
export default function BookmarksPage() {
|
export default function BookmarksPage() {
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
label: "Статьи",
|
label: "Productivity",
|
||||||
emoji: "📰",
|
emoji: "💼",
|
||||||
links: [
|
links: [
|
||||||
{ name: "Habr", url: "https://habr.com/ru/feed/", desc: "Технические статьи" },
|
{ name: "Pulse", url: "https://pulse.digital-home.site", desc: "Привычки и задачи", emoji: "💓" },
|
||||||
{ name: "VC.ru", url: "https://vc.ru/", desc: "Бизнес и технологии" },
|
{ name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dev",
|
label: "Storage",
|
||||||
emoji: "💻",
|
emoji: "💾",
|
||||||
links: [
|
links: [
|
||||||
{ name: "GitHub", url: "https://github.com/", desc: "Репозитории" },
|
{ name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" },
|
||||||
{ name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода" },
|
{ name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" },
|
||||||
{ name: "pkg.go.dev", url: "https://pkg.go.dev/", desc: "Go пакеты" },
|
|
||||||
{ name: "Flutter Docs", url: "https://docs.flutter.dev/", desc: "Документация Flutter" },
|
|
||||||
{ name: ".NET Docs", url: "https://docs.microsoft.com/dotnet/", desc: "Документация .NET" },
|
|
||||||
{ name: "Docker Hub", url: "https://hub.docker.com/", desc: "Docker образы" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "AI",
|
label: "Tools",
|
||||||
|
emoji: "🔧",
|
||||||
|
links: [
|
||||||
|
{ name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" },
|
||||||
|
{ name: "IT-Tools", url: "https://tools.digital-home.site", desc: "Утилиты разработчика", emoji: "🛠️" },
|
||||||
|
{ name: "Uptime Kuma", url: "https://uptime.digital-home.site", desc: "Мониторинг", emoji: "📊" },
|
||||||
|
{ name: "VPN Configs", url: "https://vpn.digital-home.site/admin?key=mysecret2026", desc: "Конфиги VPN", emoji: "🔒" },
|
||||||
|
{ name: "Marzban", url: "https://daniilvds.duckdns.org:2083/dashboard", desc: "Marzban панель", emoji: "🌐" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AI Subscribe",
|
||||||
emoji: "🤖",
|
emoji: "🤖",
|
||||||
links: [
|
links: [
|
||||||
{ name: "OpenRouter", url: "https://openrouter.ai/", desc: "AI роутер" },
|
{ name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" },
|
||||||
{ name: "Hugging Face", url: "https://huggingface.co/", desc: "ML модели" },
|
{ name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" },
|
||||||
{ name: "Groq Console", url: "https://console.groq.com/", desc: "Groq API" },
|
{ name: "Moonshot AI", url: "https://platform.moonshot.ai/console/account", desc: "Баланс и API ключи", emoji: "🌙" },
|
||||||
{ name: "Together AI", url: "https://api.together.xyz/", desc: "Together AI" },
|
{ name: "OpenAI API", url: "https://platform.openai.com/settings/organization/usage", desc: "OpenAI API статистика", emoji: "📈" },
|
||||||
|
{ name: "Claude API", url: "https://platform.claude.com/workspaces/default/cost", desc: "Claude API статистика", emoji: "💜" },
|
||||||
|
{ name: "ElevenLabs API", url: "https://elevenlabs.io/app/api", desc: "ElevenLabs API статистика", emoji: "🎙️" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Инфраструктура",
|
label: "Dev & References",
|
||||||
emoji: "🏗️",
|
emoji: "💻",
|
||||||
links: [
|
links: [
|
||||||
{ name: "Proxmox", url: "http://192.168.31.100:8006", desc: "Виртуализация" },
|
{ name: "GitHub", url: "https://github.com/", desc: "Репозитории", emoji: "🐱" },
|
||||||
{ name: "Tailscale", url: "https://login.tailscale.com/admin/", desc: "VPN сеть" },
|
{ name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода", emoji: "🐹" },
|
||||||
|
{ name: "pkg.go.dev", url: "https://pkg.go.dev/", desc: "Go пакеты", emoji: "📦" },
|
||||||
|
{ name: "Flutter Docs", url: "https://docs.flutter.dev/", desc: "Документация Flutter", emoji: "🐦" },
|
||||||
|
{ name: ".NET Docs", url: "https://learn.microsoft.com/ru-ru/dotnet/", desc: "Документация .NET", emoji: "🔵" },
|
||||||
|
{ name: "Docker Hub", url: "https://hub.docker.com/", desc: "Docker образы", emoji: "🐳" },
|
||||||
|
{ name: "OpenRouter", url: "https://openrouter.ai/", desc: "AI роутер", emoji: "🔀" },
|
||||||
|
{ name: "Hugging Face", url: "https://huggingface.co/", desc: "ML модели", emoji: "🤗" },
|
||||||
|
{ name: "Groq Console", url: "https://console.groq.com/", desc: "Groq API", emoji: "⚡" },
|
||||||
|
{ name: "Together AI", url: "https://api.together.ai/", desc: "Together AI", emoji: "🤝" },
|
||||||
|
{ name: "Proxmox", url: "http://192.168.31.100:8006", desc: "Виртуализация", emoji: "🖥️" },
|
||||||
|
{ name: "Tailscale", url: "https://login.tailscale.com/admin/machines", desc: "VPN сеть", emoji: "🔗" },
|
||||||
|
{ name: "Habr", url: "https://habr.com/ru/feed/", desc: "Технические статьи", emoji: "📰" },
|
||||||
|
{ name: "VC.ru", url: "https://vc.ru/", desc: "Бизнес и технологии", emoji: "📱" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -44,7 +65,7 @@ export default function BookmarksPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Bookmarks</h1>
|
<h1 className="text-2xl font-bold text-white">Bookmarks</h1>
|
||||||
<p className="text-slate-400 text-sm">Избранные ссылки</p>
|
<p className="text-slate-400 text-sm">Все ссылки в одном месте</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
@@ -62,10 +83,13 @@ export default function BookmarksPage() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="glass-card p-4 hover:scale-[1.02] transition-transform group"
|
className="glass-card p-4 hover:scale-[1.02] transition-transform group"
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{link.name}
|
<span className="text-lg">{link.emoji}</span>
|
||||||
|
<div className="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate">
|
||||||
|
{link.name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 mt-1 truncate">{link.desc}</div>
|
{link.desc && <div className="text-xs text-slate-500 mt-1 truncate">{link.desc}</div>}
|
||||||
<div className="text-xs text-slate-700 mt-2 truncate text-right group-hover:text-slate-500 transition-colors">
|
<div className="text-xs text-slate-700 mt-2 truncate text-right group-hover:text-slate-500 transition-colors">
|
||||||
{new URL(link.url).hostname}
|
{new URL(link.url).hostname}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { TasksWidget } from "@/components/widgets/TasksWidget";
|
|||||||
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
||||||
import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
|
import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
|
||||||
import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget";
|
import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget";
|
||||||
import { ServicesGrid } from "@/components/widgets/ServicesGrid";
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
@@ -26,9 +25,6 @@ export default function DashboardPage() {
|
|||||||
<ClaudeUsageWidget />
|
<ClaudeUsageWidget />
|
||||||
<ClaudeApiWidget />
|
<ClaudeApiWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
|
||||||
<ServicesGrid />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,133 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Cpu, MemoryStick, HardDrive, Clock } from "lucide-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 = {
|
const MACHINE_MAP: Record<TabName, string> = {
|
||||||
"Моя машина": { cpu: 23, ram: { used: 8.2, total: 16 }, disk: { used: 124, total: 512 }, uptime: "3д 14ч" },
|
"Openclaw": "openclaw",
|
||||||
"Сервисы": { cpu: 41, ram: { used: 6.8, total: 9.7 }, disk: { used: 47, total: 97 }, uptime: "12д 2ч" },
|
"Сервисы": "services",
|
||||||
"Рига": { cpu: 8, ram: { used: 1.1, total: 2 }, disk: { used: 18, total: 40 }, uptime: "45д 7ч" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function Sparkline({ values }: { values: number[] }) {
|
interface Metrics {
|
||||||
const max = Math.max(...values);
|
cpu: number | null;
|
||||||
const min = Math.min(...values);
|
load1: number | null;
|
||||||
const range = max - min || 1;
|
cpuCount: number;
|
||||||
const h = 32;
|
ram: { used: number; total: number; percent: number } | null;
|
||||||
const w = 80;
|
disk: { used: number; total: number; percent: number } | null;
|
||||||
const pts = values.map((v, i) => {
|
uptime: string | null;
|
||||||
const x = (i / (values.length - 1)) * w;
|
network: { rxBytes: number | null; txBytes: number | null };
|
||||||
const y = h - ((v - min) / range) * h;
|
}
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(" ");
|
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 (
|
return (
|
||||||
<svg width={w} height={h} className="opacity-60">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<polyline points={pts} fill="none" stroke="rgb(99,102,241)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<div className="relative w-24 h-14 overflow-hidden">
|
||||||
</svg>
|
<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 }: {
|
function UsageBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) {
|
||||||
icon: React.ElementType;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
sub?: string;
|
|
||||||
sparkData?: number[];
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex justify-between text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-slate-400">{label}</span>
|
||||||
<Icon className={`w-4 h-4 ${color}`} />
|
<span className="text-slate-300">{detail || `${value}%`}</span>
|
||||||
<span className="text-sm text-slate-400">{label}</span>
|
</div>
|
||||||
</div>
|
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||||
{sparkData && <Sparkline values={sparkData} />}
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-700"
|
||||||
|
style={{ width: `${value}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">{value}</div>
|
|
||||||
{sub && <div className="text-xs text-slate-500 mt-1">{sub}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemPage() {
|
export default function SystemPage() {
|
||||||
const [activeTab, setActiveTab] = useState<typeof TABS[number]>("Моя машина");
|
const [activeTab, setActiveTab] = useState<TabName>("Openclaw");
|
||||||
const data = MOCK_DATA[activeTab];
|
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const cpuHistory = Array.from({ length: 12 }, () => Math.floor(Math.random() * 40 + data.cpu - 10));
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">System</h1>
|
<div>
|
||||||
<p className="text-slate-400 text-sm">Мониторинг систем (mock данные)</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -81,65 +147,128 @@ export default function SystemPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{error ? (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="glass-card p-8 text-center">
|
||||||
<StatCard
|
<div className="text-red-400 text-sm mb-2">⚠️ Не удалось получить метрики</div>
|
||||||
icon={Cpu}
|
<div className="text-slate-500 text-xs">Убедитесь что node_exporter запущен на хосте</div>
|
||||||
label="CPU"
|
<button onClick={fetchMetrics} className="mt-4 text-indigo-400 text-sm hover:underline">
|
||||||
value={`${data.cpu}%`}
|
Попробовать снова
|
||||||
sub="Загрузка процессора"
|
</button>
|
||||||
sparkData={cpuHistory}
|
</div>
|
||||||
color="text-blue-400"
|
) : (
|
||||||
/>
|
<>
|
||||||
<StatCard
|
{/* Gauges row */}
|
||||||
icon={MemoryStick}
|
<div className="glass-card p-6">
|
||||||
label="RAM"
|
<div className="flex items-center gap-2 mb-4">
|
||||||
value={`${data.ram.used} GB`}
|
<Activity className="w-4 h-4 text-indigo-400" />
|
||||||
sub={`из ${data.ram.total} GB`}
|
<span className="text-sm font-medium text-slate-300">Загрузка</span>
|
||||||
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}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<div className="text-xs text-slate-600 text-center">
|
{/* Stats grid */}
|
||||||
⚠️ Данные примерные. Реальный мониторинг будет добавлен позже.
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
</div>
|
{[
|
||||||
|
{
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/app/api/metrics/route.ts
Normal file
79
src/app/api/metrics/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const MACHINES: Record<string, string> = {
|
||||||
|
ocplatform: "http://192.168.31.103:9100/metrics",
|
||||||
|
services: "http://node-exporter-services:9100/metrics",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseMetricValue(text: string, metricName: string): number | null {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("#") || !line.trim()) continue;
|
||||||
|
if (!line.startsWith(metricName + " ") && !line.startsWith(metricName + "{")) continue;
|
||||||
|
const parts = line.split(" ");
|
||||||
|
const val = parseFloat(parts[parts.length - 1]);
|
||||||
|
if (!isNaN(val)) return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMachineMetrics(url: string) {
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
const load1 = parseMetricValue(text, "node_load1");
|
||||||
|
const cpuCount = (text.match(/node_cpu_seconds_total\{cpu="\d+",mode="idle"\}/g) || []).length;
|
||||||
|
const cpuPercent = load1 != null && cpuCount > 0 ? Math.min(100, (load1 / cpuCount) * 100) : null;
|
||||||
|
|
||||||
|
const memTotal = parseMetricValue(text, "node_memory_MemTotal_bytes");
|
||||||
|
const memAvail = parseMetricValue(text, "node_memory_MemAvailable_bytes");
|
||||||
|
|
||||||
|
const diskTotal = parseMetricValue(text, "node_filesystem_size_bytes");
|
||||||
|
const diskAvail = parseMetricValue(text, "node_filesystem_avail_bytes");
|
||||||
|
|
||||||
|
const bootTime = parseMetricValue(text, "node_boot_time_seconds");
|
||||||
|
const uptimeSeconds = bootTime ? Date.now() / 1000 - bootTime : null;
|
||||||
|
|
||||||
|
const netRxTotal = parseMetricValue(text, "node_network_receive_bytes_total");
|
||||||
|
const netTxTotal = parseMetricValue(text, "node_network_transmit_bytes_total");
|
||||||
|
|
||||||
|
const formatUptime = (s: number) => {
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
return d > 0 ? d + "д " + h + "ч" : h + "ч";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: cpuPercent != null ? Math.round(cpuPercent) : null,
|
||||||
|
load1,
|
||||||
|
cpuCount,
|
||||||
|
ram: memTotal && memAvail ? {
|
||||||
|
used: parseFloat(((memTotal - memAvail) / 1024 / 1024 / 1024).toFixed(1)),
|
||||||
|
total: parseFloat((memTotal / 1024 / 1024 / 1024).toFixed(1)),
|
||||||
|
percent: Math.round(((memTotal - memAvail) / memTotal) * 100),
|
||||||
|
} : null,
|
||||||
|
disk: diskTotal && diskAvail ? {
|
||||||
|
used: parseFloat(((diskTotal - diskAvail) / 1024 / 1024 / 1024).toFixed(1)),
|
||||||
|
total: parseFloat((diskTotal / 1024 / 1024 / 1024).toFixed(1)),
|
||||||
|
percent: Math.round(((diskTotal - diskAvail) / diskTotal) * 100),
|
||||||
|
} : null,
|
||||||
|
uptime: uptimeSeconds ? formatUptime(uptimeSeconds) : null,
|
||||||
|
network: { rxBytes: netRxTotal, txBytes: netTxTotal },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const machine = req.nextUrl.searchParams.get("machine");
|
||||||
|
|
||||||
|
if (!machine || !MACHINES[machine]) {
|
||||||
|
return NextResponse.json({ error: "Invalid machine" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metrics = await getMachineMetrics(MACHINES[machine]);
|
||||||
|
return NextResponse.json(metrics);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch metrics", details: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,70 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
// Попробовать несколько источников
|
||||||
|
const sources = [
|
||||||
"https://wttr.in/Saint+Petersburg?format=j1",
|
"https://wttr.in/Saint+Petersburg?format=j1",
|
||||||
{
|
"https://wttr.in/Saint%20Petersburg?format=j1",
|
||||||
headers: { "User-Agent": "digital-home-dashboard/1.0" },
|
"http://wttr.in/Saint+Petersburg?format=j1",
|
||||||
next: { revalidate: 600 },
|
];
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const url of sources) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "curl/7.74.0",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e as Error;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: open-meteo (не через Cloudflare)
|
||||||
|
const geoRes = await fetch("https://geocoding-api.open-meteo.com/v1/search?name=Saint+Petersburg&country=Russia&count=1", {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const geoData = await geoRes.json();
|
||||||
|
const loc = geoData.results?.[0];
|
||||||
|
|
||||||
|
if (!loc) throw new Error("Geocoding failed");
|
||||||
|
|
||||||
|
const meteoRes = await fetch(
|
||||||
|
`https://api.open-meteo.com/v1/forecast?latitude=${loc.latitude}&longitude=${loc.longitude}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh`,
|
||||||
|
{ signal: AbortSignal.timeout(5000) }
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("Weather API error");
|
const meteoData = await meteoRes.json();
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data);
|
// Нормализовать в формат wttr.in
|
||||||
|
const wmoDescriptions: Record<number, string> = {
|
||||||
|
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
||||||
|
45: "Foggy", 48: "Icy fog", 51: "Light drizzle", 53: "Drizzle", 55: "Heavy drizzle",
|
||||||
|
61: "Light rain", 63: "Rain", 65: "Heavy rain", 71: "Light snow", 73: "Snow", 75: "Heavy snow",
|
||||||
|
80: "Light showers", 81: "Showers", 82: "Heavy showers", 95: "Thunderstorm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = meteoData.current;
|
||||||
|
const normalized = {
|
||||||
|
current_condition: [{
|
||||||
|
temp_C: Math.round(c.temperature_2m).toString(),
|
||||||
|
FeelsLikeC: Math.round(c.apparent_temperature).toString(),
|
||||||
|
humidity: Math.round(c.relative_humidity_2m).toString(),
|
||||||
|
windspeedKmph: Math.round(c.wind_speed_10m).toString(),
|
||||||
|
weatherDesc: [{ value: wmoDescriptions[c.weather_code] || "Unknown" }],
|
||||||
|
}],
|
||||||
|
source: "open-meteo",
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(normalized);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import "./globals.css";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Digital Home",
|
title: "Digital Home",
|
||||||
description: "Personal home dashboard",
|
description: "Personal home dashboard",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.svg",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { auth as middleware } from "./auth";
|
export { auth as middleware } from "./auth";
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|auth/signin).*)"],
|
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|auth).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user