Init 2
This commit is contained in:
@@ -180,11 +180,9 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
// exact same tokens as the project's Input component
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1",
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 py-1",
|
||||
"text-sm text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { asns } from "@/lib/data"
|
||||
import { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockAsns
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +41,22 @@ export default function AsnsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Автономные системы — источники маршрутов для BGP-фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={asns}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по ASN, организации…"
|
||||
searchKeys={["asn", "org", "country"]}
|
||||
columns={[
|
||||
|
||||
+35
-29
@@ -4,6 +4,7 @@ import { Fragment, useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
@@ -257,9 +258,9 @@ function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
||||
{[
|
||||
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
|
||||
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex items-center gap-2">
|
||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
||||
@@ -426,15 +427,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* search */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
|
||||
<Input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="IP, AS, описание…"
|
||||
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||||
className="h-8 pl-8 pr-8 w-52 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -522,7 +523,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
||||
{s.prefixesTx > 0
|
||||
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
|
||||
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -582,12 +583,14 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{estCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
✓ {estCnt}
|
||||
</span>
|
||||
)}
|
||||
{downCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
|
||||
⚠ {downCnt}
|
||||
</span>
|
||||
)}
|
||||
@@ -832,26 +835,29 @@ export default function BgpPage() {
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveSessions([]); return }
|
||||
if (!isLive) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err))
|
||||
setLoading(false)
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err))
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
@@ -10,9 +10,12 @@ import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { filters } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,19 +125,33 @@ const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Community | null>(null)
|
||||
|
||||
const listData = useMemo((): Community[] => {
|
||||
if (!useEvoCatalog) return COMMUNITIES
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.communities ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null)
|
||||
}, [useEvoCatalog])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return COMMUNITIES.filter(c => {
|
||||
return listData.filter(c => {
|
||||
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
|
||||
const matchT = typeFilter === "all" || c.type === typeFilter
|
||||
return matchQ && matchT
|
||||
})
|
||||
}, [search, typeFilter])
|
||||
}, [search, typeFilter, listData])
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
@@ -157,14 +174,27 @@ export default function CommunitiesPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs flex items-center gap-1.5 -mt-1",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(COMMUNITIES.length) },
|
||||
{ label: "Активных", value: String(COMMUNITIES.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(COMMUNITIES.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(COMMUNITIES.flatMap(c => c.filterIds)).size) },
|
||||
{ label: "Всего communities", value: String(listData.length) },
|
||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
|
||||
+654
-43
@@ -1,3 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -5,10 +10,43 @@ import { StatusBadge } from "@/components/status-badge"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
|
||||
import {
|
||||
servers as mockServers,
|
||||
pingProbes,
|
||||
systemEvents,
|
||||
dashLatency,
|
||||
traffic,
|
||||
serverFilterRulesets,
|
||||
} from "@/lib/data"
|
||||
import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||||
@@ -25,7 +63,7 @@ function StatCard({
|
||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{delta && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||||
{delta}
|
||||
</p>
|
||||
)}
|
||||
@@ -39,7 +77,477 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
|
||||
interface LiveKpiSnapshot {
|
||||
filters: { ruleTotal: number; serversWithRules: number } | null
|
||||
bgp: { prefixSum: number; establishedCount: number } | null
|
||||
}
|
||||
|
||||
/** Синхронизируется со страницей мониторинга (mock) */
|
||||
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
||||
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
|
||||
|
||||
function readMockDashboardStarIds(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set()
|
||||
try {
|
||||
const raw = localStorage.getItem(MOCK_DASH_STARS_LS)
|
||||
const arr = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/** Совпадает с эталоном uptime / servers */
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: ServerType
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
os: string | null
|
||||
model: string | null
|
||||
sessions?: number
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServerRow): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: s.model ?? "—",
|
||||
os: s.os ?? "—",
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as ServerStatus,
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find(s => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
const [liveServersResolved, setLiveServersResolved] = useState<Server[] | null>(null)
|
||||
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
||||
const [probesLoading, setProbesLoading] = useState(false)
|
||||
const [probesError, setProbesError] = useState<string | null>(null)
|
||||
|
||||
const probeServerCatalog = useMemo(() => {
|
||||
if (!isLive) return mockServers
|
||||
return liveServersResolved ?? []
|
||||
}, [isLive, liveServersResolved])
|
||||
|
||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
if (!silent) setProbesLoading(true)
|
||||
try {
|
||||
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
||||
setLiveProbes(overview.probes)
|
||||
setProbesError(null)
|
||||
try {
|
||||
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
||||
setLiveServersResolved(backendServers.map(mapBackendToServer))
|
||||
} catch {
|
||||
setLiveServersResolved([])
|
||||
}
|
||||
|
||||
const [fr, br] = await Promise.allSettled([
|
||||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||||
])
|
||||
|
||||
let filtersPart: LiveKpiSnapshot["filters"] = null
|
||||
if (fr.status === "fulfilled") {
|
||||
const rs = fr.value.rulesets ?? []
|
||||
const ruleTotal = rs.reduce((n, x) => n + (Array.isArray(x.rules) ? x.rules.length : 0), 0)
|
||||
const serversWithRules = rs.filter((x) => Array.isArray(x.rules) && x.rules.length > 0).length
|
||||
filtersPart = { ruleTotal, serversWithRules }
|
||||
}
|
||||
|
||||
let bgpPart: LiveKpiSnapshot["bgp"] = null
|
||||
if (br.status === "fulfilled") {
|
||||
let prefixSum = 0
|
||||
let establishedCount = 0
|
||||
for (const s of br.value) {
|
||||
const st = String(s.state ?? "")
|
||||
if (/established/i.test(st)) {
|
||||
establishedCount += 1
|
||||
prefixSum += Number(s.prefixesRx ?? 0)
|
||||
}
|
||||
}
|
||||
bgpPart = { prefixSum, establishedCount }
|
||||
}
|
||||
|
||||
setLiveKpi({ filters: filtersPart, bgp: bgpPart })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
||||
setProbesError(msg)
|
||||
setLiveKpi(null)
|
||||
if (!silent) {
|
||||
setLiveProbes(null)
|
||||
setLiveServersResolved(null)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setProbesLoading(false)
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveProbes(null)
|
||||
setLiveServersResolved(null)
|
||||
setLiveKpi(null)
|
||||
setProbesError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
void fetchProbes(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, fetchProbes])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
const id = setInterval(() => {
|
||||
queueMicrotask(() => { void fetchProbes(true) })
|
||||
}, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [isLive, fetchProbes])
|
||||
|
||||
/** Live: после звезды на мониторинге данные на дашборде должны подтянуться сразу (раньше только при монтировании и раз в 60 с). */
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
queueMicrotask(() => { void fetchProbes(true) })
|
||||
}, [pathname, isLive, fetchProbes])
|
||||
|
||||
const [mockDashEpoch, setMockDashEpoch] = useState(0)
|
||||
useEffect(() => {
|
||||
const bumpMock = () => setMockDashEpoch((x) => x + 1)
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === MOCK_DASH_STARS_LS) bumpMock()
|
||||
}
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") bumpMock()
|
||||
}
|
||||
const onUptimeChanged = () => {
|
||||
bumpMock()
|
||||
if (isLive) void fetchProbes(true)
|
||||
}
|
||||
window.addEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.addEventListener("storage", onStorage)
|
||||
document.addEventListener("visibilitychange", onVis)
|
||||
return () => {
|
||||
window.removeEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
document.removeEventListener("visibilitychange", onVis)
|
||||
}
|
||||
}, [isLive, fetchProbes])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setMockDashEpoch((x) => x + 1))
|
||||
}, [pathname])
|
||||
|
||||
const mockActiveProbes = useMemo(() => {
|
||||
void mockDashEpoch
|
||||
const stars = readMockDashboardStarIds()
|
||||
return pingProbes.filter(p => p.enabled && stars.has(p.id))
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbesTable = useMemo(() => {
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null && probesLoading) return []
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
|
||||
const probesSubtitle = useMemo(() => {
|
||||
if (!isLive) {
|
||||
const starred = mockActiveProbes.length
|
||||
return starred > 0
|
||||
? `${starred} на дашборде · мок-данные · отметьте звезды в мониторинге`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}
|
||||
if (probesError && liveProbes === null) return probesError
|
||||
const n = activeProbesTable.length
|
||||
return n > 0
|
||||
? `${n} на дашборде · последний час (API)`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
|
||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||
const serverStatusModel = useMemo(() => {
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
servers: mockServers.slice(0, 5),
|
||||
subtitle: `${mockServers.length} узлов MikroTik · демо`,
|
||||
}
|
||||
}
|
||||
if (liveServersResolved === null) {
|
||||
if (probesLoading) {
|
||||
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||
}
|
||||
return {
|
||||
kind: "unavailable" as const,
|
||||
subtitle: probesError ? "Нет данных · проверьте backend" : "Нет данных о серверах",
|
||||
}
|
||||
}
|
||||
const servers = [...liveServersResolved].sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
||||
return a.name.localeCompare(b.name, "ru")
|
||||
})
|
||||
const onlineN = servers.filter((s) => s.status === "online").length
|
||||
return {
|
||||
kind: "live" as const,
|
||||
servers,
|
||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||
}
|
||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||||
|
||||
const latencyChartBlock = useMemo(() => {
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
series: dashLatency,
|
||||
labels: undefined as Record<string, string> | undefined,
|
||||
subtitle:
|
||||
"Последние 60 минут · ping от монитора → серверы MikroTik · демо",
|
||||
}
|
||||
}
|
||||
if (liveProbes === null && probesLoading) {
|
||||
return { kind: "loading" as const }
|
||||
}
|
||||
if (!liveProbes?.length) {
|
||||
return {
|
||||
kind: "empty" as const,
|
||||
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
|
||||
}
|
||||
}
|
||||
const catalog = liveServersResolved ?? []
|
||||
const { series, labels } = buildLatencySeriesByProbeSource(
|
||||
liveProbes,
|
||||
catalog,
|
||||
{ maxServers: 8, points: 60 },
|
||||
)
|
||||
if (Object.keys(series).length === 0) {
|
||||
return {
|
||||
kind: "empty" as const,
|
||||
message:
|
||||
"Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "live" as const,
|
||||
series,
|
||||
labels,
|
||||
subtitle:
|
||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||
}
|
||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
|
||||
const dashboardKpi = useMemo(() => {
|
||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||||
const sparkFlt = [3, 4, 4, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
|
||||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||||
|
||||
if (!isLive) {
|
||||
const totalSrv = mockServers.length
|
||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||
const ruleTotal = serverFilterRulesets.reduce((n, rs) => n + rs.rules.length, 0)
|
||||
const serversWithRules = serverFilterRulesets.filter((rs) => rs.rules.length > 0).length
|
||||
const en = pingProbes.filter((p) => p.enabled)
|
||||
const down = en.filter((p) => p.status === "down").length
|
||||
const warn = en.filter((p) => p.status === "warn").length
|
||||
return {
|
||||
servers: {
|
||||
value: String(onlineSrv),
|
||||
unit: `/ ${totalSrv}`,
|
||||
delta: `${totalSrv - onlineSrv} offline`,
|
||||
deltaDir: onlineSrv < totalSrv ? ("down" as const) : ("up" as const),
|
||||
spark: sparkSrv,
|
||||
sparkColor: "var(--chart-line-1)",
|
||||
},
|
||||
filters: {
|
||||
value: String(ruleTotal),
|
||||
unit: `/ ${serverFilterRulesets.length}`,
|
||||
delta: `${serversWithRules} серверов с правилами · демо`,
|
||||
deltaDir: "up" as const,
|
||||
spark: sparkFlt,
|
||||
sparkColor: "var(--chart-line-2)",
|
||||
},
|
||||
bgp: {
|
||||
value: "8 432",
|
||||
unit: undefined as string | undefined,
|
||||
delta: "демо · не из API",
|
||||
deltaDir: "up" as const,
|
||||
spark: sparkBgp,
|
||||
sparkColor: "var(--chart-line-4)",
|
||||
},
|
||||
alerts: {
|
||||
value: String(down + warn),
|
||||
unit: undefined as string | undefined,
|
||||
delta: `${down} down · ${warn} warn`,
|
||||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: sparkAlt,
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const loadingBlock = probesLoading && liveProbes === null
|
||||
const srvList = liveServersResolved ?? []
|
||||
const totalSrv = srvList.length
|
||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||
const offlineSrv = Math.max(0, totalSrv - onlineSrv)
|
||||
|
||||
const en = liveProbes?.filter((p) => p.enabled) ?? []
|
||||
const down = en.filter((p) => p.status === "down").length
|
||||
const warn = en.filter((p) => p.status === "warn").length
|
||||
|
||||
const filters = liveKpi?.filters
|
||||
const bgp = liveKpi?.bgp
|
||||
|
||||
return {
|
||||
servers: {
|
||||
value: loadingBlock ? "—" : String(onlineSrv),
|
||||
unit: loadingBlock ? undefined : `/ ${totalSrv}`,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: offlineSrv > 0
|
||||
? `${offlineSrv} offline · опрос API`
|
||||
: totalSrv > 0
|
||||
? "Все узлы online в последнем опросе"
|
||||
: "Нет серверов в базе",
|
||||
deltaDir: offlineSrv > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-1)",
|
||||
},
|
||||
filters: {
|
||||
value: loadingBlock ? "—" : filters ? String(filters.ruleTotal) : "—",
|
||||
unit:
|
||||
filters && filters.serversWithRules > 0
|
||||
? `на ${filters.serversWithRules} серв.`
|
||||
: undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: filters
|
||||
? `${filters.serversWithRules} серверов с правилами · /api/filters/rules`
|
||||
: "Не удалось загрузить правила",
|
||||
deltaDir: filters ? ("up" as const) : ("down" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-2)",
|
||||
},
|
||||
bgp: {
|
||||
value: loadingBlock ? "—" : bgp ? fmtIntRu(bgp.prefixSum) : "—",
|
||||
unit: undefined as string | undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: bgp
|
||||
? `Σ prefixes Rx · ${bgp.establishedCount} Established · /api/bgp/sessions`
|
||||
: "Не удалось загрузить BGP",
|
||||
deltaDir: (bgp ? "up" : "down") as "up" | "down",
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-4)",
|
||||
},
|
||||
alerts: {
|
||||
value: loadingBlock ? "—" : String(down + warn),
|
||||
unit: undefined as string | undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: `${down} down · ${warn} warn · включённые пробы`,
|
||||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -57,28 +565,40 @@ export default function DashboardPage() {
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн" value="6" unit="/ 7"
|
||||
delta="+1 со вчерашнего" deltaDir="up"
|
||||
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
|
||||
sparkColor="var(--chart-line-1)"
|
||||
label="Серверы онлайн"
|
||||
value={dashboardKpi.servers.value}
|
||||
unit={dashboardKpi.servers.unit}
|
||||
delta={dashboardKpi.servers.delta}
|
||||
deltaDir={dashboardKpi.servers.deltaDir}
|
||||
spark={dashboardKpi.servers.spark}
|
||||
sparkColor={dashboardKpi.servers.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные фильтры" value="5" unit="/ 6"
|
||||
delta="2.4к доменов синхронизировано" deltaDir="up"
|
||||
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
|
||||
sparkColor="var(--chart-line-2)"
|
||||
label="Активные фильтры"
|
||||
value={dashboardKpi.filters.value}
|
||||
unit={dashboardKpi.filters.unit}
|
||||
delta={dashboardKpi.filters.delta}
|
||||
deltaDir={dashboardKpi.filters.deltaDir}
|
||||
spark={dashboardKpi.filters.spark}
|
||||
sparkColor={dashboardKpi.filters.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="BGP-префиксы" value="8 432"
|
||||
delta="+412 в последнем обновлении" deltaDir="up"
|
||||
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
|
||||
sparkColor="var(--chart-line-4)"
|
||||
label="BGP-префиксы"
|
||||
value={dashboardKpi.bgp.value}
|
||||
unit={dashboardKpi.bgp.unit}
|
||||
delta={dashboardKpi.bgp.delta}
|
||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
||||
spark={dashboardKpi.bgp.spark}
|
||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные алерты" value="4"
|
||||
delta="2 критических · 2 предупреждения" deltaDir="down"
|
||||
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
|
||||
sparkColor="var(--chart-5)"
|
||||
label="Активные алерты"
|
||||
value={dashboardKpi.alerts.value}
|
||||
unit={dashboardKpi.alerts.unit}
|
||||
delta={dashboardKpi.alerts.delta}
|
||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||||
spark={dashboardKpi.alerts.spark}
|
||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,12 +609,33 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
|
||||
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
|
||||
{latencyChartBlock.kind === "loading" && "Загрузка…"}
|
||||
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-3">
|
||||
<LatencyChart series={dashLatency} />
|
||||
{latencyChartBlock.kind === "loading" && (
|
||||
<div
|
||||
className="w-full rounded-md bg-muted/50 animate-pulse"
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
)}
|
||||
{latencyChartBlock.kind === "empty" && (
|
||||
<p className="text-sm text-muted-foreground py-10 text-center px-4">
|
||||
{latencyChartBlock.message}
|
||||
</p>
|
||||
)}
|
||||
{(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && (
|
||||
<LatencyChart
|
||||
series={latencyChartBlock.series}
|
||||
labels={latencyChartBlock.labels}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -111,9 +652,9 @@ export default function DashboardPage() {
|
||||
{systemEvents.map((e) => (
|
||||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||||
<div className="mt-0.5">
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||||
@@ -138,7 +679,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -149,26 +690,65 @@ export default function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
|
||||
{serverStatusModel.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
||||
>
|
||||
Управление →
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="divide-y divide-border">
|
||||
{servers.slice(0, 5).map((s) => (
|
||||
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<StatusDot status={s.status} pulse />
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
|
||||
{serverStatusModel.kind === "loading" && (
|
||||
<>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<div className="size-2 rounded-full bg-muted animate-pulse" />
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="h-4 rounded bg-muted/80 animate-pulse max-w-[180px]" />
|
||||
<div className="h-3 rounded bg-muted/60 animate-pulse max-w-[240px]" />
|
||||
</div>
|
||||
<div className="h-4 w-12 rounded bg-muted/60 animate-pulse" />
|
||||
<div className="h-5 w-16 rounded bg-muted/60 animate-pulse justify-self-end" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{serverStatusModel.kind === "unavailable" && (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{serverStatusModel.subtitle}
|
||||
</div>
|
||||
)}
|
||||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.length === 0 && (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
Нет серверов в базе. Добавьте узел на странице «Серверы».
|
||||
</div>
|
||||
)}
|
||||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center",
|
||||
!s.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<StatusDot status={s.status} pulse={s.status === "online"} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium leading-tight truncate">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1 truncate">
|
||||
<Flag code={s.country} className="not-mono shrink-0" />
|
||||
<span className="truncate">{s.host} · {s.site} · {s.asn}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
<span className={`text-xs font-mono tabular-nums shrink-0 ${s.latency == null ? "text-[var(--status-offline-fg)]" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground"}`}>
|
||||
{s.latency == null ? "недоступен" : `${s.latency}мс`}
|
||||
</span>
|
||||
<StatusBadge status={s.status} />
|
||||
@@ -185,9 +765,20 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
|
||||
<p className={cn(
|
||||
"text-sm mt-0.5",
|
||||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{probesSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
|
||||
<Link
|
||||
href="/uptime"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||||
>
|
||||
Открыть монитор →
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
@@ -195,7 +786,8 @@ export default function DashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||||
@@ -205,11 +797,21 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pingProbes.map((p) => {
|
||||
{isLive && probesLoading && liveProbes === null && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-6">
|
||||
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
|
||||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-5 py-2.5 align-top">
|
||||
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
@@ -218,7 +820,7 @@ export default function DashboardPage() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{p.loss}%
|
||||
{formatLossPct(p.loss)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
@@ -229,6 +831,15 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{isLive && probesError
|
||||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { domains } from "@/lib/data"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockDomains
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +41,22 @@ export default function DomainsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={domains}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
|
||||
+701
-86
File diff suppressed because it is too large
Load Diff
@@ -404,9 +404,8 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none",
|
||||
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
@@ -647,7 +646,7 @@ function ExportSheet({ open, onClose, rules }: {
|
||||
// Derived from open — new timestamp each time modal opens, undefined when closed
|
||||
const timestamp = useMemo(
|
||||
() => open ? new Date().toLocaleString("ru") : undefined,
|
||||
[open], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[open],
|
||||
)
|
||||
|
||||
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
|
||||
@@ -973,16 +972,16 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
const [addForm, setAddForm]= useState<Omit<ScenarioRule, "id">>(DEFAULT_SRULE)
|
||||
|
||||
// Reset form fields each time the sheet opens — standard modal initialization pattern
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setName(initial?.name ?? ""); setDesc(initial?.description ?? "")
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? [])
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setAddForm(DEFAULT_SRULE); setAddOpen(false)
|
||||
}, [open])
|
||||
}, [open, initial])
|
||||
|
||||
const setP = <K extends keyof PacketDef>(k: K, v: PacketDef[K]) =>
|
||||
setPkt2(p => ({ ...p, [k]: v }))
|
||||
|
||||
+228
-36
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { greTunnels, grePools, servers } from "@/lib/data"
|
||||
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
|
||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
DatabaseIcon,
|
||||
AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
@@ -42,12 +46,9 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
|
||||
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
|
||||
|
||||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||||
|
||||
function generateRosCommands(t: GreTunnel): string {
|
||||
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
||||
const lines: string[] = []
|
||||
const srv = serverById[t.serverId]
|
||||
|
||||
@@ -188,6 +189,74 @@ function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GreTunnelsApiResponse {
|
||||
tunnels: GreTunnel[]
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] {
|
||||
const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))]
|
||||
return ids.map((id) => {
|
||||
const list = tunnels.filter((t) => t.poolId === id)
|
||||
const allocated = list.length
|
||||
return {
|
||||
id,
|
||||
name: id === "live" ? "С устройств (опрос)" : id,
|
||||
cidr: "—",
|
||||
allocated,
|
||||
total: Math.max(allocated, 1),
|
||||
comment: "По poolId из данных GRE",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── default form states ─────────────────────────────────────────────────────
|
||||
|
||||
const defaultTunnelForm = {
|
||||
@@ -211,6 +280,17 @@ type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||
const [syncJhMessage, setSyncJhMessage] = useState<string | null>(null)
|
||||
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
@@ -226,8 +306,94 @@ export default function GrePage() {
|
||||
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||||
setTForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setDataLoading(true)
|
||||
setDataError(null)
|
||||
setSyncJhMessage(null)
|
||||
try {
|
||||
const [backendServers, greRes] = await Promise.all([
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
||||
])
|
||||
setLiveServers(backendServers.map(mapBackendToServer))
|
||||
setLiveTunnels(greRes.tunnels)
|
||||
} catch (e) {
|
||||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
} finally {
|
||||
setDataLoading(false)
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
setDataError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
})
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
|
||||
const displayServers = isLive ? liveServers : mockServers
|
||||
const displayPools = useMemo(
|
||||
() => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools),
|
||||
[isLive, displayTunnels],
|
||||
)
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
)
|
||||
const poolById = useMemo(
|
||||
() => Object.fromEntries(displayPools.map((p) => [p.id, p])),
|
||||
[displayPools],
|
||||
)
|
||||
|
||||
const syncJhToDb = useCallback(async () => {
|
||||
if (!isLive || syncJhBusy) return
|
||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||
if (jh.length === 0) {
|
||||
setSyncJhMessage("Нет включённых Jump Host в списке серверов")
|
||||
return
|
||||
}
|
||||
setSyncJhBusy(true)
|
||||
setSyncJhMessage(null)
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const s of jh) {
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: s.id }),
|
||||
})
|
||||
} catch (e) {
|
||||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
||||
}
|
||||
}
|
||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||
setLiveTunnels(fresh.tunnels)
|
||||
if (errors.length) {
|
||||
setSyncJhMessage(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||||
} else {
|
||||
setSyncJhMessage(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncJhMessage(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
} finally {
|
||||
setSyncJhBusy(false)
|
||||
}
|
||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return greTunnels.filter((t) => {
|
||||
return displayTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -240,16 +406,16 @@ export default function GrePage() {
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search])
|
||||
}, [tabFilter, search, displayTunnels, serverById])
|
||||
|
||||
const upCount = greTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
|
||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: greTunnels.length },
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
@@ -265,7 +431,26 @@ export default function GrePage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
@@ -276,6 +461,13 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{(dataError || syncJhMessage) && (
|
||||
<div className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-sm ${dataError ? "bg-destructive/5 border-destructive/30 text-destructive" : "bg-muted/40 border-border text-muted-foreground"}`}>
|
||||
<AlertCircleIcon className="size-5 shrink-0 mt-0.5" />
|
||||
<div>{dataError ?? syncJhMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
@@ -293,10 +485,10 @@ export default function GrePage() {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
@@ -358,11 +550,11 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t) => {
|
||||
{filtered.map((t, index) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
|
||||
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
@@ -456,7 +648,7 @@ export default function GrePage() {
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
|
||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
</Button>
|
||||
@@ -475,8 +667,8 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{grePools.map((pool) => {
|
||||
const pct = Math.round((pool.allocated / pool.total) * 100)
|
||||
{displayPools.map((pool) => {
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
@@ -516,14 +708,14 @@ export default function GrePage() {
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{grePools.map((pool) => {
|
||||
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
|
||||
{displayPools.map((pool) => {
|
||||
const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id)
|
||||
return (
|
||||
<div key={pool.id}>
|
||||
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{poolTunnels.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
{poolTunnels.map((t, tunnelIndex) => (
|
||||
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
|
||||
<span className="font-mono font-medium">{t.name}</span>
|
||||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||
@@ -574,7 +766,7 @@ export default function GrePage() {
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel)
|
||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
@@ -664,9 +856,9 @@ export default function GrePage() {
|
||||
</Field>
|
||||
<Field label="Сервер (MikroTik)" required>
|
||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
@@ -692,9 +884,9 @@ export default function GrePage() {
|
||||
<SectionTitle>Внутренний IP</SectionTitle>
|
||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать пул…</option>
|
||||
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -736,20 +928,20 @@ export default function GrePage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Шифрование">
|
||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Хеш-алгоритм">
|
||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
@@ -788,7 +980,7 @@ export default function GrePage() {
|
||||
</div>
|
||||
<Field label="DSCP">
|
||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="inherit">inherit</option>
|
||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { ipRanges } from "@/lib/data"
|
||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockIpRanges
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +42,22 @@ export default function IpRangesPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={ipRanges}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
|
||||
@@ -2,15 +2,18 @@ import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { DataSourceProvider } from "@/lib/data-source"
|
||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+893
-172
File diff suppressed because it is too large
Load Diff
+42
-28
@@ -345,10 +345,10 @@ function moveInArray<T>(arr: T[], from: number, to: number): T[] {
|
||||
|
||||
function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
|
||||
if (state === "Full" || state === "Up")
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "2-Way" || state === "Init")
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25"
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function routeTypeClass(type: OspfRoute["type"]) {
|
||||
@@ -573,8 +573,10 @@ function NodeDetailPanel({
|
||||
<span className="size-2 rounded-full shrink-0" style={{ background: theme.stroke }} />
|
||||
<span className="text-xs font-mono font-semibold" style={{ color: theme.stroke }}>{panelState}</span>
|
||||
<div className="flex gap-1.5 ml-auto">
|
||||
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-emerald-500/25 bg-emerald-500/10 text-emerald-400 font-medium">Full ×{fullCnt}</span>}
|
||||
{partCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-amber-500/25 bg-amber-500/10 text-amber-400 font-medium">2-Way ×{partCnt}</span>}
|
||||
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>Full ×{fullCnt}</span>}
|
||||
{partCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>2-Way ×{partCnt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,7 +632,9 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
// Sync with live data when it changes
|
||||
useEffect(() => { setItems(initialItems) }, [initialItems])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setItems(initialItems))
|
||||
}, [initialItems])
|
||||
|
||||
function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) }
|
||||
|
||||
@@ -708,7 +712,8 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-current/20 px-4 py-2.5 text-sm"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
<CheckIcon className="size-4 shrink-0" />{toast}
|
||||
</div>
|
||||
)}
|
||||
@@ -760,7 +765,7 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40",
|
||||
)}>
|
||||
<GripVerticalIcon className={cn("size-3.5 shrink-0", isLive ? "text-muted-foreground/10" : "text-muted-foreground/30")} />
|
||||
<span className={cn("inline-block size-1.5 rounded-full shrink-0", item.active ? "bg-emerald-500" : "bg-muted-foreground/40")} />
|
||||
<span className={cn("inline-block size-1.5 rounded-full shrink-0", item.active ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
|
||||
<code className="text-xs font-mono flex-1 min-w-0 truncate">{item.interfaceName}</code>
|
||||
{item.type && item.type !== "broadcast" && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">{item.type}</Chip>
|
||||
@@ -828,8 +833,8 @@ function NeighborsTab({
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
||||
{ label: "Full", value: fullCount, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
@@ -861,7 +866,7 @@ function NeighborsTab({
|
||||
? "bg-black/60 border-white/15 text-white/70 hover:text-white"
|
||||
: "bg-black/60 border-white/10 text-white/30 hover:text-white/60",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", showDots ? "bg-emerald-400" : "bg-white/20")} />
|
||||
<span className={cn("size-1.5 rounded-full", showDots ? "bg-[var(--status-online)]" : "bg-white/20")} />
|
||||
Анимация
|
||||
</button>
|
||||
{!selectedId && (
|
||||
@@ -1024,9 +1029,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
||||
{ label: "Up", value: upCount, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
@@ -1094,7 +1099,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
{b.packetsTx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
|
||||
<span className={cn(b.stateChanges > 3 ? "text-amber-500" : "text-muted-foreground")}>
|
||||
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
|
||||
{b.stateChanges}
|
||||
</span>
|
||||
</td>
|
||||
@@ -1142,22 +1147,31 @@ export default function OspfPage() {
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
// Reset server filter when switching live ↔ mock
|
||||
useEffect(() => { setFilterServerId("all") }, [isLive])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setFilterServerId("all"))
|
||||
}, [isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveData(null); return }
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => setLiveData(null))
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true); setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
|
||||
+544
-53
@@ -5,11 +5,13 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { servers, greTunnels } from "@/lib/data"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||
LoaderCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -25,7 +27,7 @@ interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "he
|
||||
interface DiagTest {
|
||||
id: string
|
||||
tool: DiagTool
|
||||
status: RunStatus
|
||||
status: RunStatus | "error"
|
||||
srcServerId: string
|
||||
srcServerName: string
|
||||
target: string
|
||||
@@ -33,6 +35,8 @@ interface DiagTest {
|
||||
startedAt: number
|
||||
lines: OutputLine[]
|
||||
totalLines: number // final line count — reveals progressively
|
||||
/** demo: симуляция в браузере; live: ответ MikroTik через бекенд */
|
||||
source?: "demo" | "live"
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
@@ -60,6 +64,64 @@ const TOOL_META: Record<DiagTool, {
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
||||
const l = line.toLowerCase()
|
||||
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
||||
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok"
|
||||
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
||||
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
function parseProbeOutput(text: string): OutputLine[] {
|
||||
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function jitter(base: number, pct = 0.15) {
|
||||
@@ -239,16 +301,37 @@ function genBwOutput(target: string, srcId: string, proto: string, duration: num
|
||||
|
||||
// ─── command builder ──────────────────────────────────────────────────────────
|
||||
|
||||
/** RouterOS принимает в src-address только IPv4; FQDN подставлять нельзя. */
|
||||
function isIpv4Literal(s: string): boolean {
|
||||
const t = s.trim()
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(t)) return false
|
||||
return t.split(".").every(p => {
|
||||
const n = Number.parseInt(p, 10)
|
||||
return Number.isFinite(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/** Превью: IPv4 из API, иначе литерал host если уже IP, иначе плейсхолдер пока DNS не готов. */
|
||||
function pickSrcAddressRos(host: string, resolvedLiveIpv4: string | null | undefined): string {
|
||||
if (isIpv4Literal(host)) return host.trim()
|
||||
if (resolvedLiveIpv4 === undefined) return "…"
|
||||
if (resolvedLiveIpv4 === null) return "?"
|
||||
return resolvedLiveIpv4
|
||||
}
|
||||
|
||||
function buildCommand(
|
||||
serversList: Server[],
|
||||
tool: DiagTool, src: string, target: string,
|
||||
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
|
||||
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; traceUseDns?: boolean; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
|
||||
resolvedSrcIpv4?: string | null,
|
||||
): string {
|
||||
const host = servers.find(s => s.id === src)?.host ?? "?"
|
||||
const host = serversList.find(s => s.id === src)?.host ?? "?"
|
||||
const srcRos = pickSrcAddressRos(host, resolvedSrcIpv4)
|
||||
switch (tool) {
|
||||
case "ping":
|
||||
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}`
|
||||
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${srcRos}`
|
||||
case "traceroute":
|
||||
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}`
|
||||
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} timeout=00:00:01 count=1 use-dns=${opts.traceUseDns ? "yes" : "no"} src-address=${srcRos}`
|
||||
case "bandwidth":
|
||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||
case "dns":
|
||||
@@ -256,7 +339,7 @@ function buildCommand(
|
||||
case "route":
|
||||
return `/ip route lookup ip=${target}`
|
||||
case "mtu":
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${host}`
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${srcRos}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +351,9 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-8 min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm",
|
||||
"text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
|
||||
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
@@ -350,6 +433,111 @@ function TerminalOutput({ test }: { test: DiagTest }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
||||
|
||||
function ScheduleSpeedProbesLive({
|
||||
apiFetch,
|
||||
serversForName,
|
||||
}: {
|
||||
apiFetch: ReturnType<typeof makeApiFetch>
|
||||
serversForName: Server[]
|
||||
}) {
|
||||
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
|
||||
.then(d => {
|
||||
if (!cancelled) setRows(d.probes ?? [])
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [apiFetch])
|
||||
|
||||
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-5 animate-spin" />
|
||||
Загрузка расписания из БД…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircleIcon className="size-4 shrink-0" />
|
||||
{err}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет записей speed-test в мониторинге</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
|
||||
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span>Источник</span>
|
||||
<span>Назначение</span>
|
||||
<span>Протокол</span>
|
||||
<span>Сек</span>
|
||||
<span>Вкл</span>
|
||||
<span>Последний запуск</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className={cn(
|
||||
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
|
||||
!r.enabled && "opacity-50",
|
||||
)}>
|
||||
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
|
||||
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
|
||||
<span>{r.protocol.toUpperCase()}</span>
|
||||
<span className="font-mono">{r.durationSec}s</span>
|
||||
<span>{r.enabled ? "да" : "нет"}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: SchedRule[] = [
|
||||
@@ -358,15 +546,30 @@ const INIT_RULES: SchedRule[] = [
|
||||
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
|
||||
]
|
||||
|
||||
function ScheduleTab({ rules, setRules }: {
|
||||
rules: SchedRule[]; setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
function ScheduleTab({
|
||||
rules,
|
||||
setRules,
|
||||
serverOptions,
|
||||
tunnelsForServer,
|
||||
}: {
|
||||
rules: SchedRule[]
|
||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
serverOptions: Server[]
|
||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addSrc, setAddSrc] = useState("srv1")
|
||||
const [addTun, setAddTun] = useState("gre1")
|
||||
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
|
||||
const [addTun, setAddTun] = useState("")
|
||||
const [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
const list = tunnelsForServer(addSrc)
|
||||
if (list.length && !list.some(t => t.id === addTun)) {
|
||||
setAddTun(list[0]?.id ?? "")
|
||||
}
|
||||
}, [addSrc, addTun, tunnelsForServer])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
|
||||
@@ -391,8 +594,8 @@ function ScheduleTab({ rules, setRules }: {
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rules.map(rule => {
|
||||
const src = servers.find(s => s.id === rule.srcId)
|
||||
const tun = greTunnels.find(t => t.id === rule.tunnelId)
|
||||
const src = serverOptions.find(s => s.id === rule.srcId)
|
||||
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
|
||||
return (
|
||||
<div key={rule.id} className={cn(
|
||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
@@ -433,7 +636,7 @@ function ScheduleTab({ rules, setRules }: {
|
||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||
<div><OptionLabel>Сервер</OptionLabel>
|
||||
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
|
||||
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||
@@ -475,7 +678,14 @@ function ScheduleTab({ rules, setRules }: {
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProbesPage() {
|
||||
// ── tool config ──
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
||||
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
||||
|
||||
const [tool, setTool] = useState<DiagTool>("ping")
|
||||
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
||||
const [target, setTarget] = useState("8.8.8.8")
|
||||
@@ -483,11 +693,109 @@ export default function ProbesPage() {
|
||||
const [pingSize, setPingSize] = useState(64)
|
||||
const [pingTtl] = useState(64)
|
||||
const [traceProto, setTraceProto] = useState<TraceProto>("icmp")
|
||||
const [traceUseDns, setTraceUseDns] = useState(false)
|
||||
const [traceHops] = useState(30)
|
||||
const [dnsType, setDnsType] = useState<DnsType>("A")
|
||||
const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1")
|
||||
const [bwTunId, setBwTunId] = useState("")
|
||||
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
||||
const [bwDuration, setBwDuration] = useState(10)
|
||||
const [runBusy, setRunBusy] = useState(false)
|
||||
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
||||
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => setLiveLoad("idle"))
|
||||
return
|
||||
}
|
||||
setLiveLoad("loading")
|
||||
void apiFetch<BackendServerRow[]>("/api/servers")
|
||||
.then(rows => {
|
||||
const mapped: Server[] = rows.map(s => ({
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}))
|
||||
setLiveServers(mapped)
|
||||
setLiveLoad("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setLiveServers([])
|
||||
setLiveLoad("error")
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
if (liveServers.length > 0) return liveServers
|
||||
if (liveLoad === "error") return servers
|
||||
return []
|
||||
}, [isLive, liveServers, liveLoad])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(srcId, 10)
|
||||
if (!Number.isFinite(n)) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setRosSrcV4(undefined)
|
||||
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRosSrcV4(r.ipv4)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRosSrcV4(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, srcId, apiFetch])
|
||||
|
||||
const ensureGre = useCallback(async (serverId: string) => {
|
||||
if (!isLive) return
|
||||
try {
|
||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||
)
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
|
||||
} catch {
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !srcId) return
|
||||
void ensureGre(srcId)
|
||||
}, [isLive, srcId, ensureGre])
|
||||
|
||||
const tunnelsForSrc = useMemo(() => {
|
||||
if (isLive) return greByServer[srcId] ?? []
|
||||
return greTunnels.filter(t => t.serverId === srcId)
|
||||
}, [isLive, greByServer, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const first = allServers[0]?.id
|
||||
if (!first) return
|
||||
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
|
||||
}, [allServers, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
|
||||
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
|
||||
}, [isLive, greByServer, srcId, bwTunId])
|
||||
|
||||
// ── run state ──
|
||||
const [tests, setTests] = useState<DiagTest[]>([])
|
||||
@@ -495,15 +803,18 @@ export default function ProbesPage() {
|
||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||
const nextId = useRef(1)
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
||||
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
||||
|
||||
const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
|
||||
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
|
||||
const bwTunnels = tunnelsForSrc
|
||||
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
||||
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
||||
|
||||
// current command preview
|
||||
const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, {
|
||||
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType,
|
||||
bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration,
|
||||
}), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration])
|
||||
const cmdPreview = useMemo(() => buildCommand(allServers, tool, srcId, target, {
|
||||
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, traceUseDns, dnsType,
|
||||
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
||||
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
||||
|
||||
// ── progressive reveal tick ──
|
||||
useEffect(() => {
|
||||
@@ -512,6 +823,7 @@ export default function ProbesPage() {
|
||||
const hasRunning = prev.some(t => t.status === "running")
|
||||
if (!hasRunning) return prev
|
||||
return prev.map(t => {
|
||||
if (t.source === "live") return t
|
||||
if (t.status !== "running") return t
|
||||
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
|
||||
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
|
||||
@@ -524,47 +836,180 @@ export default function ProbesPage() {
|
||||
}, [])
|
||||
|
||||
// ── run test ──
|
||||
const runTest = useCallback(() => {
|
||||
const srv = servers.find(s => s.id === srcId)
|
||||
const runTest = useCallback(async () => {
|
||||
const srv = allServers.find(s => s.id === srcId)
|
||||
if (!srv) return
|
||||
const id = String(nextId.current++)
|
||||
const id = String(nextId.current++)
|
||||
const numericServerId = Number.parseInt(srcId, 10)
|
||||
|
||||
if (isLive) {
|
||||
if (!Number.isFinite(numericServerId)) return
|
||||
setRunBusy(true)
|
||||
const abortCtrl = new AbortController()
|
||||
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
||||
setTests(p => [{
|
||||
id,
|
||||
tool,
|
||||
status: "running",
|
||||
srcServerId: srcId,
|
||||
srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||
totalLines: 1,
|
||||
source: "live",
|
||||
}, ...p.slice(0, 9)])
|
||||
setTab("history")
|
||||
try {
|
||||
const peer =
|
||||
bwTun?.remoteAddress
|
||||
? allServers.find(s => s.host.trim().toLowerCase() === bwTun.remoteAddress.trim().toLowerCase())
|
||||
: undefined
|
||||
const body = {
|
||||
tool,
|
||||
target: tool === "bandwidth" ? undefined : target.trim(),
|
||||
pingCount,
|
||||
pingSize,
|
||||
pingTtl,
|
||||
traceProto,
|
||||
traceMaxHops: traceHops,
|
||||
traceHopTimeout: tool === "traceroute" ? "1s" : undefined,
|
||||
traceProbeCount: tool === "traceroute" ? 1 : undefined,
|
||||
traceUseDns: tool === "traceroute" ? traceUseDns : undefined,
|
||||
dnsType,
|
||||
bwRemoteAddress: tool === "bandwidth" ? bwTun?.remoteAddress : undefined,
|
||||
dstServerId: peer ? Number.parseInt(peer.id, 10) : undefined,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
}
|
||||
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
const lines = parseProbeOutput(res.output ?? "")
|
||||
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines,
|
||||
totalLines: lines.length,
|
||||
status: looksErr ? "error" : "done",
|
||||
}
|
||||
: t)))
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
||||
|| (e instanceof Error && e.name === "AbortError")
|
||||
if (aborted) {
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
|
||||
totalLines: 1,
|
||||
status: "done",
|
||||
}
|
||||
: t)))
|
||||
} else {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines: [{ text: msg, kind: "err" }],
|
||||
totalLines: 1,
|
||||
status: "error",
|
||||
}
|
||||
: t)))
|
||||
}
|
||||
} finally {
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
||||
setRunBusy(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let lines: OutputLine[] = []
|
||||
const bwTun = greTunnels.find(t => t.id === bwTunId)
|
||||
|
||||
switch (tool) {
|
||||
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
|
||||
case "traceroute":lines = genTraceOutput(target, srcId, traceProto, traceHops); break
|
||||
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
|
||||
case "route": lines = genRouteOutput(target, srcId); break
|
||||
case "mtu": lines = genMtuOutput(target, srcId); break
|
||||
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
|
||||
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
|
||||
case "traceroute": lines = genTraceOutput(target, srcId, traceProto, traceHops); break
|
||||
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
|
||||
case "route": lines = genRouteOutput(target, srcId); break
|
||||
case "mtu": lines = genMtuOutput(target, srcId); break
|
||||
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
|
||||
}
|
||||
|
||||
const test: DiagTest = {
|
||||
id, tool, status: "running",
|
||||
srcServerId: srcId, srcServerName: srv.name,
|
||||
id,
|
||||
tool,
|
||||
status: "done",
|
||||
srcServerId: srcId,
|
||||
srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines,
|
||||
totalLines: 0,
|
||||
totalLines: lines.length,
|
||||
source: "demo",
|
||||
}
|
||||
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
|
||||
setTests(p => [test, ...p.slice(0, 9)])
|
||||
setTab("history")
|
||||
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
|
||||
}, [
|
||||
isLive,
|
||||
allServers,
|
||||
srcId,
|
||||
tool,
|
||||
target,
|
||||
pingCount,
|
||||
pingSize,
|
||||
pingTtl,
|
||||
traceProto,
|
||||
traceUseDns,
|
||||
traceHops,
|
||||
dnsType,
|
||||
bwTun,
|
||||
bwTunId,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
cmdPreview,
|
||||
apiFetch,
|
||||
])
|
||||
|
||||
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
|
||||
const stopTest = (id: string) => {
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}
|
||||
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
|
||||
|
||||
const running = tests.filter(t => t.status === "running")
|
||||
|
||||
if (isLive && liveLoad === "loading") {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-8 animate-spin opacity-50" />
|
||||
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
liveProbeRunRef.current?.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: undefined
|
||||
@@ -574,6 +1019,19 @@ export default function ProbesPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{isLive && liveLoad === "error" && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── tool selector + config ── */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
@@ -604,7 +1062,7 @@ export default function ProbesPage() {
|
||||
<div>
|
||||
<OptionLabel>Источник</OptionLabel>
|
||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
||||
{servers.filter(s => s.enabled).map(s => (
|
||||
{allServers.filter(s => s.enabled).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
@@ -653,12 +1111,23 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{tool === "traceroute" && (
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 pb-0.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<OptionLabel>Имена хопов (use-dns)</OptionLabel>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug max-w-[220px]">
|
||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tool === "dns" && (
|
||||
@@ -687,8 +1156,13 @@ export default function ProbesPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
|
||||
<PlayIcon className="size-3.5" />Запустить
|
||||
<Button
|
||||
onClick={() => void runTest()}
|
||||
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
|
||||
className="h-8 shrink-0 gap-1.5 self-end"
|
||||
>
|
||||
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
||||
Запустить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -765,11 +1239,17 @@ export default function ProbesPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{test.status === "done" && (
|
||||
{test.status === "done" && test.source !== "live" && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{((test.lines.length * 0.4)).toFixed(1)}с
|
||||
</span>
|
||||
)}
|
||||
{test.status === "done" && test.source === "live" && (
|
||||
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
|
||||
)}
|
||||
{test.status === "error" && (
|
||||
<span className="text-[11px] text-destructive">ошибка</span>
|
||||
)}
|
||||
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
||||
<CopyIcon className="size-3.5" />
|
||||
@@ -792,7 +1272,18 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{/* schedule */}
|
||||
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
|
||||
{tab === "schedule" && (
|
||||
isLive
|
||||
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
|
||||
: (
|
||||
<ScheduleTab
|
||||
rules={rules}
|
||||
setRules={setRules}
|
||||
serverOptions={allServers}
|
||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+222
-171
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo } from "react"
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -8,6 +9,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
type HomeRouter,
|
||||
type JumpHost,
|
||||
type ExitNode,
|
||||
type WanJhLeg,
|
||||
type JhExLeg,
|
||||
type FullRoute,
|
||||
type CommRec,
|
||||
type HomeEntry,
|
||||
type OptimizerData,
|
||||
type OptimizerSettings,
|
||||
type OptimizerApiServer,
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
mapApiServersToTopology,
|
||||
readStoredRouteOptimizerSettings,
|
||||
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
|
||||
} from "@/lib/route-optimizer-data"
|
||||
import {
|
||||
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
||||
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
|
||||
@@ -16,111 +36,29 @@ import {
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WanUplink {
|
||||
id: string
|
||||
name: string // "WAN1-RT"
|
||||
isp: string // "Rostelecom"
|
||||
iface: string // "ether1"
|
||||
ip: string // external IP
|
||||
maxDl: number // Mbps
|
||||
maxUl: number // Mbps
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
interface HomeRouter {
|
||||
id: string
|
||||
label: string // "home-msk-01"
|
||||
site: string // "MSK"
|
||||
country: string
|
||||
model: string
|
||||
ip: string // LAN management IP
|
||||
wans: WanUplink[]
|
||||
}
|
||||
|
||||
interface JumpHost {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
interface ExitNode {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
// WAN→JH measurement (per uplink)
|
||||
interface WanJhLeg {
|
||||
wanId: string
|
||||
jhId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
score: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
// JH→Exit measurement
|
||||
interface JhExLeg {
|
||||
jhId: string
|
||||
exitId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
// Full route: HomeRouter + WAN → JH → Exit
|
||||
interface FullRoute {
|
||||
id: string
|
||||
homeId: string
|
||||
wan: WanUplink
|
||||
jh: JumpHost
|
||||
exit: ExitNode
|
||||
hw: WanJhLeg
|
||||
je: JhExLeg
|
||||
score: number
|
||||
confidence: "HIGH" | "MEDIUM" | "LOW"
|
||||
probabilityOptimal: number
|
||||
}
|
||||
|
||||
// BGP community recommendation
|
||||
interface CommRec {
|
||||
community: string
|
||||
communityName: string
|
||||
current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
shouldSwitch: boolean
|
||||
pinnedBySettings: boolean
|
||||
}
|
||||
|
||||
interface HomeEntry {
|
||||
home: HomeRouter
|
||||
wanJhLegs: WanJhLeg[] // all (WAN × JH) measurements
|
||||
fullRoutes: FullRoute[] // sorted by probabilityOptimal
|
||||
bestRoute: FullRoute | null
|
||||
commRecs: CommRec[]
|
||||
}
|
||||
|
||||
interface OptimizerData {
|
||||
updatedAt: string
|
||||
homes: HomeEntry[]
|
||||
}
|
||||
|
||||
interface OptimizerSettings {
|
||||
switchThreshold: number
|
||||
hysteresisThreshold: number
|
||||
pingWeight: number
|
||||
probeIntervalMin: number
|
||||
autoApply: boolean
|
||||
autoApplyIntervalMin: number
|
||||
}
|
||||
|
||||
// ─── Topology derived from lib/data servers ───────────────────────────────────
|
||||
// ─── Topology derived from lib/data servers (mock) ───────────────────────────
|
||||
|
||||
const HOME_ROUTERS: HomeRouter[] = servers
|
||||
.filter(s => s.type === "home-router" && s.enabled)
|
||||
@@ -361,13 +299,14 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
|
||||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||||
|
||||
function WanMatrix({ home, legs, pw: _pw }: {
|
||||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
pw: number
|
||||
}) {
|
||||
// find best leg overall
|
||||
const bestScore = Math.max(...legs.map(l => l.score))
|
||||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -377,7 +316,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||||
{JUMPHOSTS.map(jh => (
|
||||
{jumpHosts.map(jh => (
|
||||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||||
<div>{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
@@ -412,7 +351,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</td>
|
||||
{/* Per-JH cells */}
|
||||
{JUMPHOSTS.map(jh => {
|
||||
{jumpHosts.map(jh => {
|
||||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||||
const isBest = leg.score === bestScore
|
||||
@@ -682,8 +621,9 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
||||
|
||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||
|
||||
function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
entry: HomeEntry
|
||||
jumpHosts: JumpHost[]
|
||||
settings: OptimizerSettings
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
@@ -741,7 +681,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex items-center gap-0 border-b bg-muted/20 px-1">
|
||||
{([
|
||||
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${JUMPHOSTS.length})` },
|
||||
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${jumpHosts.length})` },
|
||||
{ id: "full-routes", label: `Маршруты (${fullRoutes.length})` },
|
||||
{ id: "bgp-community", label: `BGP community (${commRecs.length})` },
|
||||
] as { id: HomeTab; label: string }[]).map(t => (
|
||||
@@ -759,7 +699,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "wan-matrix" && (
|
||||
<WanMatrix home={home} legs={wanJhLegs} pw={settings.pingWeight} />
|
||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||||
)}
|
||||
{tab === "full-routes" && (
|
||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
@@ -807,49 +747,111 @@ function TopologyBar() {
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_SETTINGS: OptimizerSettings = {
|
||||
switchThreshold: 15,
|
||||
hysteresisThreshold: 10,
|
||||
pingWeight: 60,
|
||||
probeIntervalMin: 15,
|
||||
autoApply: false,
|
||||
autoApplyIntervalMin: 60,
|
||||
}
|
||||
|
||||
export default function RouteOptimizerPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_SETTINGS)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
// ECMP / RPF / VRF
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
|
||||
const useLiveData = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const load = useCallback((s: OptimizerSettings = settings) => {
|
||||
setLoading(true); setError("")
|
||||
setTimeout(() => {
|
||||
try { setData(buildMockData(s)) }
|
||||
catch { setError("Ошибка расчёта маршрутов") }
|
||||
finally { setLoading(false) }
|
||||
}, 600)
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||
const settingsRef = useRef(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
// Initial load + polling every 30 s — load() calls setState internally (standard data-fetching pattern)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
const id = setInterval(() => load(), 30_000)
|
||||
return () => clearInterval(id)
|
||||
queueMicrotask(() => {
|
||||
setSettings(readStoredRouteOptimizerSettings())
|
||||
setPrefsLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
try {
|
||||
localStorage.setItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}, [settings, prefsLoaded])
|
||||
|
||||
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
|
||||
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
|
||||
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||
const s = override ?? settingsRef.current
|
||||
setLoading(true)
|
||||
setError("")
|
||||
try {
|
||||
if (!useLiveData) {
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
setData(buildMockData(s))
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
return
|
||||
}
|
||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||||
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
||||
setLiveJumpHosts(jumpHosts)
|
||||
setLiveExitNodes(exitNodes)
|
||||
|
||||
let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null
|
||||
try {
|
||||
const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>(
|
||||
"/api/filters/rules",
|
||||
)
|
||||
rulesets = fr.rulesets ?? null
|
||||
} catch {
|
||||
rulesets = null
|
||||
}
|
||||
|
||||
setData(buildLiveOptimizerData(rows, rulesets, s))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
setData(null)
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiFetch, useLiveData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
queueMicrotask(() => {
|
||||
void load()
|
||||
})
|
||||
}, [load, useLiveData, prefsLoaded])
|
||||
|
||||
const pollMs = useMemo(() => {
|
||||
if (!useLiveData) return 30_000
|
||||
const m = Math.min(Math.max(settings.probeIntervalMin, 1), 30)
|
||||
return m * 60_000
|
||||
}, [useLiveData, settings.probeIntervalMin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
const id = setInterval(() => {
|
||||
void load()
|
||||
}, pollMs)
|
||||
return () => clearInterval(id)
|
||||
}, [load, pollMs, prefsLoaded])
|
||||
|
||||
function togglePin(key: string) {
|
||||
setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n })
|
||||
}
|
||||
@@ -863,10 +865,61 @@ export default function RouteOptimizerPage() {
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const totalSwitches = useMemo(() =>
|
||||
data?.homes.flatMap(h => h.commRecs)
|
||||
.filter(r => r.shouldSwitch && !pinned.has(r.community)).length ?? 0
|
||||
, [data, pinned])
|
||||
const totalSwitches = useMemo(
|
||||
() =>
|
||||
data?.homes.flatMap((h) =>
|
||||
h.commRecs.filter(
|
||||
(r) => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`),
|
||||
),
|
||||
).length ?? 0,
|
||||
[data, pinned],
|
||||
)
|
||||
|
||||
const jumpHostsForCards = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
|
||||
const statsChips = useMemo(() => {
|
||||
const homeCount = useLiveData ? (data?.homes.length ?? 0) : HOME_ROUTERS.length
|
||||
const wanCount = useLiveData
|
||||
? (data?.homes.reduce((s, h) => s + h.home.wans.length, 0) ?? 0)
|
||||
: HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)
|
||||
const jh = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
const ex = useLiveData ? liveExitNodes : EXIT_NODES
|
||||
const jhSub = jh.length ? jh.map((j) => j.site).join(" · ") : "—"
|
||||
const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—"
|
||||
return [
|
||||
{
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: (
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||
|
||||
function applyAll() {
|
||||
data?.homes.forEach(h =>
|
||||
@@ -890,7 +943,7 @@ export default function RouteOptimizerPage() {
|
||||
<ZapIcon className="size-4" />Применить все ({totalSwitches})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
{loading ? "Расчёт…" : "Обновить"}
|
||||
</Button>
|
||||
@@ -907,26 +960,18 @@ export default function RouteOptimizerPage() {
|
||||
<span className="text-border">·</span>
|
||||
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>Авто 30 с</span>
|
||||
<Chip>симуляция</Chip>
|
||||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||||
<Chip>{useLiveData ? "Живые данные · API" : "Демо · mock"}</Chip>
|
||||
{useLiveData && backendStatus === false && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
бекенд не отвечает на /health — проверьте URL в настройках
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Home роутеров", value: HOME_ROUTERS.length,
|
||||
sub: `${HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "JumpHost", value: JUMPHOSTS.length,
|
||||
sub: JUMPHOSTS.map(j => j.site).join(" · "),
|
||||
icon: <ServerIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "Exit Node", value: EXIT_NODES.length,
|
||||
sub: EXIT_NODES.map(e => e.site).join(" · "),
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Переключений", value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: <ZapIcon className={cn("size-4", totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground")} /> },
|
||||
].map(s => (
|
||||
{statsChips.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-4 py-3 flex items-start justify-between">
|
||||
<div>
|
||||
@@ -1022,11 +1067,16 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setSettings(DEFAULT_SETTINGS)}>Сбросить</Button>
|
||||
<Button size="sm" onClick={() => load(settings)}>
|
||||
<Button variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
|
||||
<Button size="sm" onClick={() => void load(settings)}>
|
||||
<CheckCircleIcon className="size-4" />Применить
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-3">
|
||||
Базовые значения совпадают с разделом{" "}
|
||||
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1173,10 +1223,11 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
|
||||
{/* Per-home-router cards */}
|
||||
{data?.homes.map(entry => (
|
||||
{data?.homes.map((entry) => (
|
||||
<HomeRouterCard
|
||||
key={entry.home.id}
|
||||
entry={entry}
|
||||
jumpHosts={jumpHostsForCards}
|
||||
settings={settings}
|
||||
pinned={pinned}
|
||||
applied={applied}
|
||||
|
||||
+43
-14
@@ -15,6 +15,8 @@ interface BackendServer {
|
||||
useSsl: boolean; verifySsl: boolean; username: string; password: string
|
||||
type: ServerType; site: string; country: string; asn: string
|
||||
comment: string; enabled: boolean
|
||||
lanSubnet: string
|
||||
wanUplinks: WanUplink[]
|
||||
status: "online" | "offline" | null; latency: number | null
|
||||
os: string | null; model: string | null; uptime: string | null
|
||||
cpuLoad: number | null; freeMemory: number | null; totalMemory: number | null
|
||||
@@ -39,10 +41,14 @@ function toFrontend(s: BackendServer): Server {
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions ?? 0,
|
||||
comment: s.comment || undefined,
|
||||
lanSubnet: s.lanSubnet || undefined,
|
||||
wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined,
|
||||
// carry extra fields needed for expanded view
|
||||
uptime: s.uptime ?? undefined,
|
||||
cpuLoad: s.cpuLoad ?? undefined,
|
||||
polledAt: s.polledAt ?? undefined,
|
||||
uptime: s.uptime ?? undefined,
|
||||
cpuLoad: s.cpuLoad ?? undefined,
|
||||
freeMemory: s.freeMemory ?? undefined,
|
||||
totalMemory: s.totalMemory ?? undefined,
|
||||
polledAt: s.polledAt ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +119,10 @@ const ROS_FEATURES: RosFeature[] = [
|
||||
function RosBadge({ os }: { os: string }) {
|
||||
const v = rosVer(os)
|
||||
const cls = v >= 715
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||
: v >= 710
|
||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||
return (
|
||||
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
|
||||
{os}
|
||||
@@ -390,8 +396,10 @@ export default function ServersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "live") {
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
queueMicrotask(() => {
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
apiFetch<BackendServer[]>("/api/servers")
|
||||
@@ -437,6 +445,11 @@ export default function ServersPage() {
|
||||
port: String(full.port ?? 443),
|
||||
proto: full.useSsl ? "https" : "http",
|
||||
verifySsl: full.verifySsl ?? false,
|
||||
lanSubnet: full.lanSubnet ?? prev.lanSubnet,
|
||||
wanUplinks:
|
||||
Array.isArray(full.wanUplinks) && full.wanUplinks.length
|
||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||
: prev.wanUplinks,
|
||||
}))
|
||||
} catch {
|
||||
// ignore — user can fill in manually
|
||||
@@ -459,6 +472,8 @@ export default function ServersPage() {
|
||||
asn: form.asn,
|
||||
comment: form.comment,
|
||||
enabled: form.enabled,
|
||||
lanSubnet: form.lanSubnet.trim(),
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
@@ -484,9 +499,19 @@ export default function ServersPage() {
|
||||
if (sheetMode === "edit" && editingId) {
|
||||
setServerList(list => list.map(s =>
|
||||
s.id === editingId
|
||||
? { ...s, name: form.name, type: form.type, site: form.site, country: form.country,
|
||||
host: form.host, asn: form.asn, comment: form.comment,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline" }
|
||||
? {
|
||||
...s,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
site: form.site,
|
||||
country: form.country,
|
||||
host: form.host,
|
||||
asn: form.asn,
|
||||
comment: form.comment,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
||||
}
|
||||
: s
|
||||
))
|
||||
} else {
|
||||
@@ -497,6 +522,8 @@ export default function ServersPage() {
|
||||
status: form.enabled ? "online" : "offline",
|
||||
enabled: form.enabled, latency: null, sessions: 0,
|
||||
comment: form.comment || undefined,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
}])
|
||||
}
|
||||
}
|
||||
@@ -764,7 +791,7 @@ export default function ServersPage() {
|
||||
</td>
|
||||
<td className={cn("px-4 py-3 font-mono text-right text-sm",
|
||||
s.latency == null ? "text-muted-foreground"
|
||||
: s.latency > 60 ? "text-amber-500" : "")}>
|
||||
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
|
||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
|
||||
@@ -1068,12 +1095,14 @@ export default function ServersPage() {
|
||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||
</Button>
|
||||
{testState === "ok" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
<CheckCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
{testState === "error" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||
style={{ background: "var(--status-offline-bg)", color: "var(--status-offline-fg)" }}>
|
||||
<XCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+525
-11
@@ -1,13 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
|
||||
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||
@@ -18,7 +21,7 @@ import { servers } from "@/lib/data"
|
||||
import {
|
||||
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon,
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -144,9 +147,43 @@ const PERM_COLOR: Record<PermLevel, string> = {
|
||||
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
}
|
||||
|
||||
const SECTIONS_NAV = ["Общие", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
type NavSection = typeof SECTIONS_NAV[number]
|
||||
|
||||
interface CollectorSettingsDto {
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
probeIntervalSec?: number
|
||||
speedIntervalSec?: number
|
||||
retentionDays: number
|
||||
lastCollectedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
collectorRunning?: boolean
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message || "Ошибка запроса")
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── small components ─────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
@@ -815,7 +852,28 @@ export default function SettingsPage() {
|
||||
|
||||
// data source
|
||||
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [evoBaseDraft, setEvoBaseDraft] = useState("")
|
||||
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
|
||||
const [evoKeyDraft, setEvoKeyDraft] = useState("")
|
||||
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
|
||||
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
|
||||
const [urlDraft, setUrlDraft] = useState(backendUrl)
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
||||
const [showEvoKey, setShowEvoKey] = useState(false)
|
||||
|
||||
// collectors
|
||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
|
||||
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
|
||||
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
|
||||
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
|
||||
const [collectorError, setCollectorError] = useState<string | null>(null)
|
||||
|
||||
// general
|
||||
const [lang, setLang] = useState("ru")
|
||||
@@ -848,8 +906,6 @@ export default function SettingsPage() {
|
||||
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
|
||||
const [auditLog, setAuditLog] = useState(true)
|
||||
|
||||
const handleSave = () => { setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||||
@@ -871,7 +927,84 @@ export default function SettingsPage() {
|
||||
// total sub-users count for summary
|
||||
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
|
||||
|
||||
const loadCollectors = useCallback(async () => {
|
||||
if (mode !== "live" || backendStatus !== true) return
|
||||
setCollectorError(null)
|
||||
try {
|
||||
const [traffic, uptime] = await Promise.all([
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
|
||||
])
|
||||
setTrafficCollector(traffic)
|
||||
setUptimeCollector(uptime)
|
||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
|
||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
|
||||
}
|
||||
}, [apiFetch, backendStatus, mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Сбор данных") return
|
||||
queueMicrotask(() => { void loadCollectors() })
|
||||
}, [section, loadCollectors])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "EvoBGP") return
|
||||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||||
}, [section, mode, backendStatus, evo.loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "EvoBGP" || !evo.settingsLoaded) return
|
||||
setEvoBaseDraft(evo.baseUrl)
|
||||
setEvoEnabledDraft(evo.enabled)
|
||||
setEvoKeyDraft("")
|
||||
setEvoSaveErr(null)
|
||||
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (section === "EvoBGP") {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
return
|
||||
}
|
||||
setEvoSaveBusy(true)
|
||||
setEvoSaveErr(null)
|
||||
try {
|
||||
const patch: EvoBgpSavePayload = {
|
||||
baseUrl: evoBaseDraft.trim(),
|
||||
enabled: evoEnabledDraft,
|
||||
}
|
||||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}, [
|
||||
section,
|
||||
mode,
|
||||
backendStatus,
|
||||
evoBaseDraft,
|
||||
evoEnabledDraft,
|
||||
evoKeyDraft,
|
||||
evo.saveSettings,
|
||||
])
|
||||
|
||||
const renderContent = () => {
|
||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
|
||||
// ── Общие ──
|
||||
if (section === "Общие") return (
|
||||
@@ -966,7 +1099,7 @@ export default function SettingsPage() {
|
||||
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Язык интерфейса">
|
||||
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={lang} onChange={e => setLang(e.target.value)}>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
@@ -984,7 +1117,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="Часовой пояс">
|
||||
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
||||
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
@@ -1002,6 +1135,370 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card id="route-ai" className="scroll-mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Route AI</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
|
||||
веса настраиваются в инструменте, не через отдельный API.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-5 pb-5">
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Гистерезис</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Автоприменение</dt>
|
||||
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Link
|
||||
href="/route-optimizer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
Открыть оптимизатор маршрутов
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Сбор данных ──
|
||||
if (section === "Сбор данных") return (
|
||||
<div className="space-y-4">
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{mode === "live" && backendStatus === true && (
|
||||
<>
|
||||
{collectorError && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-xs text-destructive">{collectorError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Сбор трафика</CardTitle>
|
||||
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-5 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">Состояние</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "traffic"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Вкл</button>
|
||||
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "traffic"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
|
||||
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
|
||||
await loadCollectors()
|
||||
} finally { setCollectorBusy(null) }
|
||||
}}>Сохранить</Button>
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Собрать сейчас</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Сбор uptime</CardTitle>
|
||||
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-5 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">Состояние</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "uptime"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Вкл</button>
|
||||
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "uptime"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
|
||||
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try {
|
||||
await apiFetch("/api/uptime/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
|
||||
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
|
||||
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
|
||||
}),
|
||||
})
|
||||
await loadCollectors()
|
||||
} finally { setCollectorBusy(null) }
|
||||
}}>Сохранить</Button>
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Собрать сейчас</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── EvoBGP ──
|
||||
if (section === "EvoBGP") return (
|
||||
<div className="space-y-4">
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">EvoBGP API</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||||
<a
|
||||
className="underline underline-offset-2"
|
||||
href="https://git.shts.su/denozord/EvoBGP/src/branch/main/docs/access.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
access.md
|
||||
</a>
|
||||
. URL и ключ хранятся в SQLite на сервере бекенда (
|
||||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||||
). Каталог —{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y px-5 space-y-4 pb-5">
|
||||
<SettingRow
|
||||
label="Базовый URL API"
|
||||
description="Например http://control.example:8080 — без суффикса /v1"
|
||||
>
|
||||
<Input
|
||||
className="w-full max-w-md h-8 text-sm font-mono"
|
||||
value={evoBaseDraft}
|
||||
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="API-ключ"
|
||||
description={
|
||||
evo.secretConfigured
|
||||
? "В БД уже есть ключ — введите новый только для замены"
|
||||
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-full max-w-md h-8 text-sm font-mono"
|
||||
type={showEvoKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
value={evoKeyDraft}
|
||||
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
||||
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => setShowEvoKey((v) => !v)}
|
||||
>
|
||||
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Подставлять данные EvoBGP"
|
||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||
>
|
||||
<Toggle
|
||||
checked={evoEnabledDraft}
|
||||
onChange={(v) => setEvoEnabledDraft(v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
||||
onClick={async () => {
|
||||
setEvoSaveBusy(true)
|
||||
setEvoSaveErr(null)
|
||||
try {
|
||||
await evo.saveSettings({ apiKey: null })
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Удалить ключ из БД
|
||||
</Button>
|
||||
</div>
|
||||
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={
|
||||
mode !== "live" ||
|
||||
backendStatus !== true ||
|
||||
evoBusy !== null ||
|
||||
evoSaveBusy ||
|
||||
!(
|
||||
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
||||
(evoKeyDraft.trim() || evo.secretConfigured)
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
setEvoBusy("test")
|
||||
setEvoTestResult(null)
|
||||
const b = evoBaseDraft.trim()
|
||||
const k = evoKeyDraft.trim()
|
||||
let draft: EvoBgpTestDraft | undefined
|
||||
if (b || k) {
|
||||
draft = {}
|
||||
if (b) draft.baseUrl = b
|
||||
if (k) draft.apiKey = k
|
||||
}
|
||||
const r = await evo.testConnection(draft)
|
||||
setEvoTestResult({ ok: r.ok, message: r.message })
|
||||
setEvoBusy(null)
|
||||
}}
|
||||
>
|
||||
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||
Проверить ключ
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
||||
onClick={async () => {
|
||||
setEvoBusy("refresh")
|
||||
await evo.refresh()
|
||||
setEvoBusy(null)
|
||||
}}
|
||||
>
|
||||
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||
Обновить каталог
|
||||
</Button>
|
||||
{evo.snapshot?.fetchedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{evoTestResult && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
||||
)}>
|
||||
{evoTestResult.message}
|
||||
</p>
|
||||
)}
|
||||
{evo.error && (
|
||||
<p className="text-xs text-destructive">{evo.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1306,9 +1803,26 @@ export default function SettingsPage() {
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{saved ? <CheckIcon className="size-4" /> : <SaveIcon className="size-4" />}
|
||||
{saved ? "Сохранено!" : "Сохранить"}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { void handleSave() }}
|
||||
disabled={
|
||||
section === "EvoBGP" &&
|
||||
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
||||
}
|
||||
>
|
||||
{section === "EvoBGP" && evoSaveBusy ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
{section === "EvoBGP" && evoSaveBusy
|
||||
? "Сохранение…"
|
||||
: saved
|
||||
? "Сохранено!"
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -204,8 +204,8 @@ function Terminal({
|
||||
const [lines, setLines] = useState<TermLine[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const [idCtr, setIdCtr] = useState(0)
|
||||
const [_histIdx, setHistIdx] = useState(-1)
|
||||
const [_idCtr, setIdCtr] = useState(0)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -217,16 +217,6 @@ function Terminal({
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => {
|
||||
setLines(prev => [
|
||||
...prev,
|
||||
...texts.map(text => ({ id: 0, kind, text })),
|
||||
].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i }))
|
||||
// Note: IDs don't need to be perfect unique here since we append
|
||||
)
|
||||
setIdCtr(prev => prev + texts.length)
|
||||
}, [])
|
||||
|
||||
// Initialize MOTD on mount / server change
|
||||
useEffect(() => {
|
||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
||||
@@ -447,27 +437,30 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveServers([]); return }
|
||||
if (!isLive) return
|
||||
let cancelled = false
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
|
||||
@@ -483,11 +476,15 @@ export default function TerminalPage() {
|
||||
}, [termServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
queueMicrotask(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
})
|
||||
}, [defaultUid, selectedUid])
|
||||
|
||||
// Reset selection when switching modes
|
||||
useEffect(() => { setSelectedUid("") }, [isLive])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setSelectedUid(""))
|
||||
}, [isLive])
|
||||
|
||||
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
||||
|
||||
|
||||
+3
-141
@@ -116,16 +116,6 @@ interface LiveTrafficServer {
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
interface TrafficSettingsDto {
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
retentionDays: number
|
||||
lastCollectedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
collectorRunning: boolean
|
||||
}
|
||||
|
||||
interface LiveTrafficInterface {
|
||||
name: string
|
||||
running: boolean
|
||||
@@ -787,10 +777,6 @@ export default function TrafficPage() {
|
||||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||||
const [liveBusy, setLiveBusy] = useState(false)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [settingsBusy, setSettingsBusy] = useState(false)
|
||||
const [trafficSettings, setTrafficSettings] = useState<TrafficSettingsDto | null>(null)
|
||||
const [intervalDraft, setIntervalDraft] = useState("30")
|
||||
const [retentionDraft, setRetentionDraft] = useState("14")
|
||||
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
|
||||
const [selectedIface, setSelectedIface] = useState("__all__")
|
||||
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
|
||||
@@ -834,30 +820,16 @@ export default function TrafficPage() {
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
const loadTrafficSettings = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
try {
|
||||
const s = await apiFetch<TrafficSettingsDto>("/api/traffic/settings")
|
||||
setTrafficSettings(s)
|
||||
setIntervalDraft(String(s.intervalSec))
|
||||
setRetentionDraft(String(s.retentionDays))
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить настройки сбора")
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLiveServers([])
|
||||
setLiveError(null)
|
||||
setTrafficSettings(null)
|
||||
return
|
||||
}
|
||||
if (groupMode !== "servers") setGroupMode("servers")
|
||||
void loadLiveTraffic(range)
|
||||
void loadTrafficSettings()
|
||||
}, [isLive, groupMode, range, loadLiveTraffic, loadTrafficSettings])
|
||||
}, [isLive, groupMode, range, loadLiveTraffic])
|
||||
|
||||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||||
const visibleIfaces = useMemo(
|
||||
@@ -974,28 +946,6 @@ export default function TrafficPage() {
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
</Button>
|
||||
{isLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
await apiFetch("/api/traffic/collect-now", { method: "POST" })
|
||||
await loadLiveTraffic(range)
|
||||
await loadTrafficSettings()
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось выполнить сбор")
|
||||
} finally {
|
||||
setSettingsBusy(false)
|
||||
}
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>
|
||||
<ActivityIcon className={cn("size-4", settingsBusy && "animate-pulse")} />Собрать сейчас
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
@@ -1020,94 +970,6 @@ export default function TrafficPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{isLive && trafficSettings && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Сбор статистики</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button
|
||||
className={cn("px-3 text-xs", trafficSettings.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) })
|
||||
await loadTrafficSettings()
|
||||
} finally { setSettingsBusy(false) }
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>Вкл</button>
|
||||
<button
|
||||
className={cn("px-3 text-xs border-l border-input", !trafficSettings.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) })
|
||||
await loadTrafficSettings()
|
||||
} finally { setSettingsBusy(false) }
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Интервал (сек)</span>
|
||||
<input
|
||||
value={intervalDraft}
|
||||
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||
className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Хранение (дней)</span>
|
||||
<input
|
||||
value={retentionDraft}
|
||||
onChange={(e) => setRetentionDraft(e.target.value)}
|
||||
className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={settingsBusy}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
intervalSec: Number.parseInt(intervalDraft, 10) || 30,
|
||||
retentionDays: Number.parseInt(retentionDraft, 10) || 14,
|
||||
}),
|
||||
})
|
||||
await loadTrafficSettings()
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось сохранить настройки")
|
||||
} finally {
|
||||
setSettingsBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить сбор
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {trafficSettings.lastCollectedAt ? new Date(trafficSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {trafficSettings.lastDurationMs != null ? `${trafficSettings.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(trafficSettings.lastError ? "text-destructive" : "")}>
|
||||
{trafficSettings.lastError ? `Ошибка: ${trafficSettings.lastError}` : "Ошибок нет"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── summary stat cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
@@ -1206,11 +1068,11 @@ export default function TrafficPage() {
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{visibleIfaces.map((iface) => {
|
||||
{visibleIfaces.map((iface, index) => {
|
||||
const active = selectedIface === iface.name
|
||||
return (
|
||||
<button
|
||||
key={iface.name}
|
||||
key={`${iface.name}:${index}`}
|
||||
onClick={() => setSelectedIface(iface.name)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||
|
||||
+1374
-412
File diff suppressed because it is too large
Load Diff
+39
-7
@@ -110,9 +110,16 @@
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* ── Status ── */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* badge variants — foreground text + translucent fill */
|
||||
--status-online-fg: oklch(0.437 0.149 150.069); /* emerald-700 */
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
|
||||
--status-offline-fg: oklch(0.505 0.213 27.325); /* red-700 */
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
|
||||
--status-degraded-fg: oklch(0.555 0.163 58.318); /* amber-700 */
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
|
||||
}
|
||||
|
||||
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
|
||||
@@ -171,10 +178,17 @@
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* Status stays the same */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* Status — same dot colors, dark-mode-adjusted badge text */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* badge variants — lighter fg for dark bg */
|
||||
--status-online-fg: oklch(0.696 0.170 151.328); /* emerald-400 */
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
|
||||
--status-offline-fg: oklch(0.704 0.191 22.216); /* red-400 */
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
|
||||
--status-degraded-fg: oklch(0.769 0.149 70.080); /* amber-400 */
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -186,5 +200,23 @@
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
color-scheme: light;
|
||||
}
|
||||
/* next-themes кладёт класс dark на <html> — без этого нативные контролы (select) дают светлый popup и белый текст */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
Нативный <select>: в Chrome/Edge выпадающая часть может игнорировать тёмный фон страницы.
|
||||
Явные цвета + color-scheme выше дают читаемые пункты в светлой и тёмной теме.
|
||||
*/
|
||||
select {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
select option {
|
||||
background-color: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-19
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
@@ -20,18 +19,6 @@ export const metadata: Metadata = {
|
||||
description: "MikroTik network management platform",
|
||||
};
|
||||
|
||||
// Runs before React hydration — prevents flash of wrong theme (FOUC).
|
||||
const ANTI_FOUC = `
|
||||
try {
|
||||
var t = localStorage.getItem('rl-theme') || 'dark';
|
||||
var d = t === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
: t;
|
||||
document.documentElement.classList.toggle('dark', d === 'dark');
|
||||
document.documentElement.classList.toggle('light', d === 'light');
|
||||
} catch(e) {}
|
||||
`.trim()
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -43,13 +30,8 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
<Script id="anti-fouc" strategy="beforeInteractive">
|
||||
{ANTI_FOUC}
|
||||
</Script>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user