Add server monitoring and database backup pages
New sidebar sections wired to the backend's new admin APIs: - Monitoring: live request/error-rate/latency stats plus a recent-requests table, polling every 8s. - Backups: create/list/download database backups (auth'd blob download since the endpoint requires a Bearer token, not a plain link).
This commit is contained in:
@@ -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<BackupInfo[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [downloadingFile, setDownloadingFile] = useState<string | null>(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 <FullScreenLoader label="لیست بکآپها رو میاریم..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||||
|
بکآپ دیتابیس 💾
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
یک نسخهی کامل و فشرده از دیتابیس بگیر یا نسخههای قبلی رو دانلود کن.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||||
|
تازهسازی
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" icon={<Database size={15} />} isLoading={creating} onClick={handleCreate}>
|
||||||
|
ساخت بکآپ جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{!backups || backups.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-14 text-center">
|
||||||
|
<HardDrive size={28} className="text-(--color-content-tertiary)" />
|
||||||
|
<p className="text-[14px] font-bold text-(--color-content-primary)">
|
||||||
|
هنوز هیچ بکآپی گرفته نشده
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||||
|
با دکمهی «ساخت بکآپ جدید» اولین نسخه رو بگیر.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-(--color-border-subtle)">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<div key={b.filename} className="flex items-center justify-between px-5 py-4 gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p dir="ltr" className="font-bold text-[13px] text-(--color-content-primary) truncate">
|
||||||
|
{b.filename}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{new Date(b.created_at).toLocaleString("fa-IR")} · {formatSize(b.size_bytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Download size={14} />}
|
||||||
|
isLoading={downloadingFile === b.filename}
|
||||||
|
onClick={() => handleDownload(b.filename)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
دانلود
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-(--radius-md) bg-(--color-bg-secondary) flex items-center justify-center shrink-0 ${toneClasses}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">{label}</p>
|
||||||
|
<p className="text-[19px] font-extrabold text-(--color-content-primary)">{value}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MonitoringSummary | null>(null);
|
||||||
|
const [requests, setRequests] = useState<RequestLogEntry[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <FullScreenLoader label="آمار سرور رو میاریم..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||||
|
مانیتورینگ سرور 📡
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
وضعیت زندهی بکاند — هر {POLL_MS / 1000} ثانیه خودکار بهروز میشه.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||||
|
تازهسازی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="کل درخواستها (از زمان اجرا)"
|
||||||
|
value={summary.total_requests.toLocaleString("fa-IR")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Zap size={18} />}
|
||||||
|
label="درخواست در دقیقهی اخیر"
|
||||||
|
value={summary.requests_last_min.toLocaleString("fa-IR")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
label="زمان پاسخ میانگین"
|
||||||
|
value={`${summary.avg_latency_ms.toFixed(1)} ms`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Gauge size={18} />}
|
||||||
|
label="زمان پاسخ p95"
|
||||||
|
value={`${summary.p95_latency_ms.toFixed(1)} ms`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
label="نرخ خطا"
|
||||||
|
value={`${summary.error_rate_pct.toFixed(1)}%`}
|
||||||
|
tone={summary.error_rate_pct > 5 ? "error" : summary.error_rate_pct > 0 ? "warning" : "neutral"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="مدت زمان روشن بودن سرور"
|
||||||
|
value={formatUptime(summary.uptime_seconds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-(--color-border-subtle)">
|
||||||
|
<h2 className="font-bold text-[15px]">آخرین درخواستها</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">متد</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">مسیر</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">وضعیت</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">مدت</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">IP</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">زمان</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center text-(--color-content-tertiary) py-8">
|
||||||
|
هنوز درخواستی ثبت نشده
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
requests.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-(--color-border-subtle) last:border-0">
|
||||||
|
<td className="px-5 py-2.5 font-bold" dir="ltr">
|
||||||
|
{r.method}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 font-mono text-[12px] text-(--color-content-secondary)" dir="ltr">
|
||||||
|
{r.path || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5">
|
||||||
|
<Badge tone={statusTone(r.status_code)}>{r.status_code}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5" dir="ltr">
|
||||||
|
{r.duration_ms.toFixed(1)} ms
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-(--color-content-tertiary)" dir="ltr">
|
||||||
|
{r.client_ip}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-(--color-content-tertiary)">
|
||||||
|
{new Date(r.time).toLocaleTimeString("fa-IR")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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 { cn } from "@/lib/utils";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
@@ -11,6 +20,8 @@ const navItems = [
|
|||||||
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
||||||
{ href: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
{ href: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
||||||
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
||||||
|
{ href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity },
|
||||||
|
{ href: "/backups", label: "بکآپ دیتابیس", icon: Database },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
@@ -306,3 +306,72 @@ export function rejectUser(id: number, reason: string) {
|
|||||||
body: JSON.stringify({ reason }),
|
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<MonitoringSummary>("/admin/monitoring/summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentRequests(limit = 100) {
|
||||||
|
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
||||||
|
|
||||||
|
export interface BackupInfo {
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups() {
|
||||||
|
return authRequest<BackupInfo[]>("/admin/backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackup() {
|
||||||
|
return authRequest<BackupInfo>("/admin/backups", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// دانلود بکآپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمیشه
|
||||||
|
// مستقیم از یک <a href> استفاده کرد؛ فایل رو با 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user