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 (
-
+
);
}
-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 ? (
+
+ ) : (
+
+
+
+
+
+ )}
- ))}
-
-
- ⚠️ Данные примерные. Реальный мониторинг будет добавлен позже.
-
+ {/* 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).*)"],
};