From cacf84c22e7d84d958fcb953bbe0f2720f2e4742 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 15 Apr 2026 21:16:57 +0000 Subject: [PATCH] Update: metrics API, favicon, weather fix, middleware fix, system improvements --- .dockerignore | 8 +- public/favicon.svg | 10 + src/app/(dashboard)/bookmarks/page.tsx | 74 ++++-- src/app/(dashboard)/page.tsx | 4 - src/app/(dashboard)/system/page.tsx | 331 +++++++++++++++++-------- src/app/api/metrics/route.ts | 79 ++++++ src/app/api/weather/route.ts | 68 ++++- src/app/layout.tsx | 3 + src/middleware.ts | 2 +- 9 files changed, 438 insertions(+), 141 deletions(-) create mode 100644 public/favicon.svg create mode 100644 src/app/api/metrics/route.ts diff --git a/.dockerignore b/.dockerignore index 5a31b49..9f3048b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,8 @@ +.git +.gitignore node_modules .next -.git *.md -.env -.env.local +.env* +Dockerfile +.dockerignore diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..64cfa1a --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/(dashboard)/bookmarks/page.tsx b/src/app/(dashboard)/bookmarks/page.tsx index f4216f7..7e08b3e 100644 --- a/src/app/(dashboard)/bookmarks/page.tsx +++ b/src/app/(dashboard)/bookmarks/page.tsx @@ -1,41 +1,62 @@ export default function BookmarksPage() { const categories = [ { - label: "Статьи", - emoji: "📰", + label: "Productivity", + emoji: "💼", links: [ - { name: "Habr", url: "https://habr.com/ru/feed/", desc: "Технические статьи" }, - { name: "VC.ru", url: "https://vc.ru/", desc: "Бизнес и технологии" }, + { name: "Pulse", url: "https://pulse.digital-home.site", desc: "Привычки и задачи", emoji: "💓" }, + { name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" }, ], }, { - label: "Dev", - emoji: "💻", + label: "Storage", + emoji: "💾", links: [ - { name: "GitHub", url: "https://github.com/", desc: "Репозитории" }, - { name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода" }, - { 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 образы" }, + { name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" }, + { name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" }, ], }, { - 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: "🤖", links: [ - { name: "OpenRouter", url: "https://openrouter.ai/", desc: "AI роутер" }, - { name: "Hugging Face", url: "https://huggingface.co/", desc: "ML модели" }, - { name: "Groq Console", url: "https://console.groq.com/", desc: "Groq API" }, - { name: "Together AI", url: "https://api.together.xyz/", desc: "Together AI" }, + { name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" }, + { name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" }, + { name: "Moonshot AI", url: "https://platform.moonshot.ai/console/account", desc: "Баланс и API ключи", emoji: "🌙" }, + { 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: "Инфраструктура", - emoji: "🏗️", + label: "Dev & References", + emoji: "💻", links: [ - { name: "Proxmox", url: "http://192.168.31.100:8006", desc: "Виртуализация" }, - { name: "Tailscale", url: "https://login.tailscale.com/admin/", desc: "VPN сеть" }, + { name: "GitHub", url: "https://github.com/", desc: "Репозитории", emoji: "🐱" }, + { 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() {

Bookmarks

-

Избранные ссылки

+

Все ссылки в одном месте

{categories.map((cat) => ( @@ -62,10 +83,13 @@ export default function BookmarksPage() { rel="noopener noreferrer" className="glass-card p-4 hover:scale-[1.02] transition-transform group" > -
- {link.name} +
+ {link.emoji} +
+ {link.name} +
-
{link.desc}
+ {link.desc &&
{link.desc}
}
{new URL(link.url).hostname}
diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index b77c6dd..2c26906 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -4,7 +4,6 @@ import { TasksWidget } from "@/components/widgets/TasksWidget"; import { CalendarWidget } from "@/components/widgets/CalendarWidget"; import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget"; import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget"; -import { ServicesGrid } from "@/components/widgets/ServicesGrid"; export default function DashboardPage() { return ( @@ -26,9 +25,6 @@ export default function DashboardPage() {
- - {/* Services */} -
); } diff --git a/src/app/(dashboard)/system/page.tsx b/src/app/(dashboard)/system/page.tsx index c1a593e..f9861b3 100644 --- a/src/app/(dashboard)/system/page.tsx +++ b/src/app/(dashboard)/system/page.tsx @@ -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 = { + "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 ( - - - +
+
+ + + + +
+ {value}% +
+
+ {label} +
); } -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 ( -
-
-
- - {label} -
- {sparkData && } +
+
+ {label} + {detail || `${value}%`} +
+
+
-
{value}
- {sub &&
{sub}
}
); } export default function SystemPage() { - const [activeTab, setActiveTab] = useState("Моя машина"); - const data = MOCK_DATA[activeTab]; - - const cpuHistory = Array.from({ length: 12 }, () => Math.floor(Math.random() * 40 + data.cpu - 10)); + const [activeTab, setActiveTab] = useState("Openclaw"); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [lastUpdated, setLastUpdated] = useState(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 (
-
-

System

-

Мониторинг систем (mock данные)

+
+
+

System Monitor

+

+ {lastUpdated ? `Обновлено: ${lastUpdated.toLocaleTimeString("ru-RU")}` : "Загрузка..."} +

+
+
{/* Tabs */} @@ -81,65 +147,128 @@ export default function SystemPage() { ))}
- {/* Stats */} -
- - - - -
- - {/* Usage bars */} -
-

Использование ресурсов

- {[ - { 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) => ( -
-
- {item.label} - {item.value.toFixed(0)}% -
-
-
+ {error ? ( +
+
⚠️ Не удалось получить метрики
+
Убедитесь что node_exporter запущен на хосте
+ +
+ ) : ( + <> + {/* Gauges row */} +
+
+ + Загрузка
+ {loading ? ( +
+ {[1,2,3].map(i =>
)} +
+ ) : ( +
+ + + +
+ )}
- ))} -
-
- ⚠️ Данные примерные. Реальный мониторинг будет добавлен позже. -
+ {/* Stats grid */} +
+ {[ + { + 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) => ( +
+
+ + {item.label} +
+
{item.value}
+ {item.sub &&
{item.sub}
} +
+ ))} +
+ + {/* Usage bars */} + {!loading && metrics && ( +
+

Использование ресурсов

+ + {metrics.ram && ( + + )} + {metrics.disk && ( + + )} +
+ )} + + {/* Network */} + {!loading && metrics?.network && ( +
+
+ + Сеть (всего) +
+
+
+
↓ Получено
+
{formatBytes(metrics.network.rxBytes)}
+
+
+
↑ Отправлено
+
{formatBytes(metrics.network.txBytes)}
+
+
+
+ )} + + )}
); } diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts new file mode 100644 index 0000000..712b432 --- /dev/null +++ b/src/app/api/metrics/route.ts @@ -0,0 +1,79 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; + +const MACHINES: Record = { + 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 }); + } +} diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts index 0a5176e..f69bcd5 100644 --- a/src/app/api/weather/route.ts +++ b/src/app/api/weather/route.ts @@ -3,16 +3,70 @@ import { NextResponse } from "next/server"; export async function GET() { try { - const res = await fetch( + // Попробовать несколько источников + const sources = [ "https://wttr.in/Saint+Petersburg?format=j1", - { - headers: { "User-Agent": "digital-home-dashboard/1.0" }, - next: { revalidate: 600 }, + "https://wttr.in/Saint%20Petersburg?format=j1", + "http://wttr.in/Saint+Petersburg?format=j1", + ]; + + 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 data = await res.json(); - return NextResponse.json(data); + const meteoData = await meteoRes.json(); + + // Нормализовать в формат wttr.in + const wmoDescriptions: Record = { + 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) { return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index adc9886..2f83bf6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,9 @@ import "./globals.css"; export const metadata: Metadata = { title: "Digital Home", description: "Personal home dashboard", + icons: { + icon: "/favicon.svg", + }, }; export default function RootLayout({ diff --git a/src/middleware.ts b/src/middleware.ts index 76dc05c..fa1a99e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,5 @@ export { auth as middleware } from "./auth"; export const config = { - matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|auth/signin).*)"], + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|auth).*)"], };