diff --git a/src/app/(dashboard)/backups/page.tsx b/src/app/(dashboard)/backups/page.tsx new file mode 100644 index 0000000..c770e55 --- /dev/null +++ b/src/app/(dashboard)/backups/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Database, Download, HardDrive, RefreshCw } from "lucide-react"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { ErrorBanner } from "@/components/ui/ErrorBanner"; +import { FullScreenLoader } from "@/components/ui/FullScreenLoader"; +import { useToast } from "@/components/ui/Toast"; +import { formatApiError } from "@/lib/hooks"; +import { BackupInfo, listBackups, createBackup, downloadBackup } from "@/lib/api"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function BackupsPage() { + const toast = useToast(); + const [backups, setBackups] = useState(null); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + const [downloadingFile, setDownloadingFile] = useState(null); + + const load = useCallback(async () => { + try { + setBackups(await listBackups()); + setError(null); + } catch (err) { + setError(formatApiError(err)); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load(); + }, [load]); + + async function handleCreate() { + setCreating(true); + try { + await createBackup(); + toast.showSuccess("بک‌آپ جدید با موفقیت ساخته شد"); + await load(); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setCreating(false); + } + } + + async function handleDownload(filename: string) { + setDownloadingFile(filename); + try { + await downloadBackup(filename); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setDownloadingFile(null); + } + } + + if (!backups && !error) return ; + + return ( +
+
+
+

+ بک‌آپ دیتابیس 💾 +

+

+ یک نسخه‌ی کامل و فشرده از دیتابیس بگیر یا نسخه‌های قبلی رو دانلود کن. +

+
+
+ + +
+
+ + {error && } + + + {!backups || backups.length === 0 ? ( +
+ +

+ هنوز هیچ بک‌آپی گرفته نشده +

+

+ با دکمه‌ی «ساخت بک‌آپ جدید» اولین نسخه رو بگیر. +

+
+ ) : ( +
+ {backups.map((b) => ( +
+
+

+ {b.filename} +

+

+ {new Date(b.created_at).toLocaleString("fa-IR")} · {formatSize(b.size_bytes)} +

+
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/monitoring/page.tsx b/src/app/(dashboard)/monitoring/page.tsx new file mode 100644 index 0000000..ea3b478 --- /dev/null +++ b/src/app/(dashboard)/monitoring/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Activity, AlertTriangle, Clock, Gauge, RefreshCw, Zap } from "lucide-react"; +import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { ErrorBanner } from "@/components/ui/ErrorBanner"; +import { FullScreenLoader } from "@/components/ui/FullScreenLoader"; +import { formatApiError } from "@/lib/hooks"; +import { + MonitoringSummary, + RequestLogEntry, + getMonitoringSummary, + getRecentRequests, +} from "@/lib/api"; + +const POLL_MS = 8_000; + +function StatCard({ + icon, + label, + value, + tone = "neutral", +}: { + icon: React.ReactNode; + label: string; + value: string; + tone?: "neutral" | "warning" | "error"; +}) { + const toneClasses = + tone === "error" + ? "text-(--color-status-error)" + : tone === "warning" + ? "text-amber-600" + : "text-(--color-brand-600)"; + return ( + +
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ); +} + +function statusTone(code: number): "success" | "warning" | "error" | "neutral" { + if (code >= 500) return "error"; + if (code >= 400) return "warning"; + if (code >= 200 && code < 300) return "success"; + return "neutral"; +} + +function formatUptime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h} ساعت و ${m} دقیقه`; + return `${m} دقیقه`; +} + +export default function MonitoringPage() { + const [summary, setSummary] = useState(null); + const [requests, setRequests] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const [s, r] = await Promise.all([getMonitoringSummary(), getRecentRequests(50)]); + setSummary(s); + setRequests(r); + setError(null); + } catch (err) { + setError(formatApiError(err)); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load(); + const interval = setInterval(load, POLL_MS); + return () => clearInterval(interval); + }, [load]); + + if (!summary || !requests) { + if (error) { + return ( +
+ +
+ ); + } + return ; + } + + return ( +
+
+
+

+ مانیتورینگ سرور 📡 +

+

+ وضعیت زنده‌ی بک‌اند — هر {POLL_MS / 1000} ثانیه خودکار به‌روز می‌شه. +

+
+ +
+ + {error && } + +
+ } + label="کل درخواست‌ها (از زمان اجرا)" + value={summary.total_requests.toLocaleString("fa-IR")} + /> + } + label="درخواست در دقیقه‌ی اخیر" + value={summary.requests_last_min.toLocaleString("fa-IR")} + /> + } + label="زمان پاسخ میانگین" + value={`${summary.avg_latency_ms.toFixed(1)} ms`} + /> + } + label="زمان پاسخ p95" + value={`${summary.p95_latency_ms.toFixed(1)} ms`} + /> + } + label="نرخ خطا" + value={`${summary.error_rate_pct.toFixed(1)}%`} + tone={summary.error_rate_pct > 5 ? "error" : summary.error_rate_pct > 0 ? "warning" : "neutral"} + /> + } + label="مدت زمان روشن بودن سرور" + value={formatUptime(summary.uptime_seconds)} + /> +
+ + +
+

آخرین درخواست‌ها

+
+
+ + + + + + + + + + + + + {requests.length === 0 ? ( + + + + ) : ( + requests.map((r, i) => ( + + + + + + + + + )) + )} + +
متدمسیروضعیتمدتIPزمان
+ هنوز درخواستی ثبت نشده +
+ {r.method} + + {r.path || "—"} + + {r.status_code} + + {r.duration_ms.toFixed(1)} ms + + {r.client_ip} + + {new Date(r.time).toLocaleTimeString("fa-IR")} +
+
+
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9fdd3bb..be95a30 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,16 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { LayoutGrid, ListTree, MessageCircleQuestion, LogOut, Heart, UserCheck } from "lucide-react"; +import { + LayoutGrid, + ListTree, + MessageCircleQuestion, + LogOut, + Heart, + UserCheck, + Activity, + Database, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuth } from "@/lib/auth-context"; @@ -11,6 +20,8 @@ const navItems = [ { href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck }, { href: "/categories", label: "دسته‌بندی‌ها و گزینه‌ها", icon: ListTree }, { href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion }, + { href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity }, + { href: "/backups", label: "بک‌آپ دیتابیس", icon: Database }, ]; export function Sidebar() { diff --git a/src/lib/api.ts b/src/lib/api.ts index 0fa6d00..89efa71 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -306,3 +306,72 @@ export function rejectUser(id: number, reason: string) { body: JSON.stringify({ reason }), }); } + +// ---------------- ادمین: مانیتورینگ ---------------- + +export interface MonitoringSummary { + total_requests: number; + total_errors: number; + error_rate_pct: number; + avg_latency_ms: number; + p95_latency_ms: number; + requests_last_min: number; + started_at: string; + uptime_seconds: number; +} + +export interface RequestLogEntry { + time: string; + method: string; + path: string; + status_code: number; + duration_ms: number; + client_ip: string; + user_id?: number; +} + +export function getMonitoringSummary() { + return authRequest("/admin/monitoring/summary"); +} + +export function getRecentRequests(limit = 100) { + return authRequest(`/admin/monitoring/requests?limit=${limit}`); +} + +// ---------------- ادمین: بک‌آپ دیتابیس ---------------- + +export interface BackupInfo { + filename: string; + size_bytes: number; + created_at: string; +} + +export function listBackups() { + return authRequest("/admin/backups"); +} + +export function createBackup() { + return authRequest("/admin/backups", { method: "POST" }); +} + +// دانلود بک‌آپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمی‌شه +// مستقیم از یک استفاده کرد؛ فایل رو با fetch احرازهویت‌شده می‌گیریم و +// خودمون دانلودش رو با یک لینک موقت شبیه‌سازی می‌کنیم. +export async function downloadBackup(filename: string) { + const token = tokenStore.getAccess(); + const res = await fetch(`${API_BASE}/admin/backups/${encodeURIComponent(filename)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + throw new ApiError("دانلود بک‌آپ ناموفق بود", res.status); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}