Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality.
1064 lines
44 KiB
TypeScript
1064 lines
44 KiB
TypeScript
"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"
|
||
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 { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
||
import {
|
||
servers as mockServers,
|
||
pingProbes,
|
||
dashLatency,
|
||
traffic,
|
||
serverFilterRulesets,
|
||
} from "@/lib/data"
|
||
import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||
import type { GreTunnel } 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, buttonVariants } from "@/components/ui/button"
|
||
import { cn } from "@/lib/utils"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||
import {
|
||
buildDashboardInternetPath,
|
||
type HomeWanRuntime,
|
||
resolveDefaultRouteLookup,
|
||
type InternetPathViewModel,
|
||
} from "@/lib/dashboard-internet-path"
|
||
import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data"
|
||
import { listEvents } from "@/shared/api/events"
|
||
import type { EventItem } from "@/packages/contracts/src/events"
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function StatCard({
|
||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||
}: {
|
||
label: string; value: string; unit?: string; delta?: string
|
||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||
}) {
|
||
return (
|
||
<Card className="relative overflow-hidden">
|
||
<CardContent className="pt-5 pb-4 px-5">
|
||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||
<div className="flex items-baseline gap-1.5 mt-1">
|
||
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||
{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-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||
{delta}
|
||
</p>
|
||
)}
|
||
{spark && spark.length > 1 && (
|
||
<div className="absolute right-4 bottom-4 opacity-60">
|
||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
function formatEventAge(iso: string): string {
|
||
const ts = Date.parse(iso)
|
||
if (!Number.isFinite(ts)) return "—"
|
||
const diffMs = Math.max(0, Date.now() - ts)
|
||
const minutes = Math.floor(diffMs / 60_000)
|
||
if (minutes < 1) return "сейчас"
|
||
if (minutes < 60) return `${minutes}м`
|
||
const hours = Math.floor(minutes / 60)
|
||
if (hours < 24) return `${hours}ч`
|
||
const days = Math.floor(hours / 24)
|
||
return `${days}д`
|
||
}
|
||
|
||
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
|
||
wanUplinks?: Array<{
|
||
id: string
|
||
name: string
|
||
isp: string
|
||
iface: string
|
||
ip: string
|
||
maxDl: number
|
||
maxUl: number
|
||
}>
|
||
}
|
||
|
||
interface ApiGreTunnelRow {
|
||
id: string
|
||
name: string
|
||
serverId: string
|
||
localAddress: string
|
||
remoteAddress: string
|
||
localInnerIp: string
|
||
remoteInnerIp: string
|
||
poolId: string
|
||
ipsec: null
|
||
mtu: number
|
||
keepaliveInterval: number
|
||
keepaliveRetries: number
|
||
dscp: "inherit" | number
|
||
clampTcpMss: boolean
|
||
allowFastPath: boolean
|
||
comment: string
|
||
enabled: boolean
|
||
status: "up" | "down" | "degraded"
|
||
}
|
||
|
||
interface InternetPathSnapshotPayload {
|
||
sampledAt: string
|
||
servers: BackendServerRow[]
|
||
greTunnels: ApiGreTunnelRow[]
|
||
filtersRulesets: FiltersRulesetRow[]
|
||
speedProbes: RouteOptimizerSpeedProbe[]
|
||
routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null } | null>
|
||
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
|
||
}
|
||
|
||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
serverId: String(t.serverId),
|
||
localAddress: t.localAddress,
|
||
remoteAddress: t.remoteAddress,
|
||
localInnerIp: t.localInnerIp,
|
||
remoteInnerIp: t.remoteInnerIp,
|
||
poolId: t.poolId || "live",
|
||
ipsec: null,
|
||
mtu: t.mtu,
|
||
keepaliveInterval: t.keepaliveInterval,
|
||
keepaliveRetries: t.keepaliveRetries,
|
||
dscp: t.dscp,
|
||
clampTcpMss: t.clampTcpMss,
|
||
allowFastPath: t.allowFastPath,
|
||
comment: t.comment,
|
||
enabled: t.enabled,
|
||
status: t.status,
|
||
}
|
||
}
|
||
|
||
function mapBackendToServer(s: BackendServerRow): Server {
|
||
const wanUplinks = Array.isArray(s.wanUplinks)
|
||
? s.wanUplinks
|
||
.filter((w) => typeof w === "object" && w != null)
|
||
.map((w, idx) => ({
|
||
id: String(w.id || `wan-${s.id}-${idx + 1}`),
|
||
name: String(w.name || `WAN${idx + 1}`),
|
||
isp: String(w.isp || "—"),
|
||
iface: String(w.iface || ""),
|
||
ip: String(w.ip || ""),
|
||
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)),
|
||
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)),
|
||
}))
|
||
: []
|
||
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,
|
||
wanUplinks,
|
||
}
|
||
}
|
||
|
||
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 [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||
const [eventsLoading, setEventsLoading] = useState(false)
|
||
const [eventsError, setEventsError] = useState<string | null>(null)
|
||
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
|
||
const [internetPathLoading, setInternetPathLoading] = useState(false)
|
||
const [internetPathError, setInternetPathError] = 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)
|
||
if (!silent) setInternetPathLoading(true)
|
||
try {
|
||
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
||
setLiveProbes(overview.probes)
|
||
setProbesError(null)
|
||
setInternetPathError(null)
|
||
let serversMapped: Server[] = []
|
||
try {
|
||
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
||
serversMapped = backendServers.map(mapBackendToServer)
|
||
setLiveServersResolved(serversMapped)
|
||
} catch {
|
||
serversMapped = []
|
||
setLiveServersResolved([])
|
||
}
|
||
|
||
const [fr, br, ipRes] = await Promise.allSettled([
|
||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
|
||
])
|
||
|
||
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 })
|
||
if (serversMapped.length > 0) {
|
||
const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null
|
||
if (snap) {
|
||
const snapshotServers = snap.servers.map(mapBackendToServer)
|
||
setInternetPath(buildDashboardInternetPath({
|
||
servers: snapshotServers,
|
||
greTunnels: (snap.greTunnels ?? []).map(apiGreToGreTunnel),
|
||
probes: snap.speedProbes ?? [],
|
||
filtersRulesets: snap.filtersRulesets ?? [],
|
||
routeLookupByServerId: snap.routeLookupByServerId ?? {},
|
||
wanRuntimeByHomeId: snap.wanRuntimeByHomeId ?? {},
|
||
}))
|
||
} else {
|
||
const filterRulesets: FiltersRulesetRow[] =
|
||
fr.status === "fulfilled"
|
||
? (fr.value.rulesets as FiltersRulesetRow[] ?? [])
|
||
: []
|
||
const homes = serversMapped.filter((s) => s.type === "home-router")
|
||
const lookups = await Promise.all(
|
||
homes.map(async (h) => ({
|
||
id: h.id,
|
||
lookup: await resolveDefaultRouteLookup(apiFetch, h.id),
|
||
})),
|
||
)
|
||
const wanRuntimeRows = await Promise.all(
|
||
homes.map(async (h) => {
|
||
try {
|
||
const rt = await apiFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
|
||
return { id: h.id, runtime: rt }
|
||
} catch {
|
||
return { id: h.id, runtime: null }
|
||
}
|
||
}),
|
||
)
|
||
const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(() => ({ probes: [] }))
|
||
const greRes = await apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] }))
|
||
const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup]))
|
||
const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime]))
|
||
setInternetPath(buildDashboardInternetPath({
|
||
servers: serversMapped,
|
||
greTunnels: (greRes.tunnels ?? []).map(apiGreToGreTunnel),
|
||
probes: speedRes.probes ?? [],
|
||
filtersRulesets: filterRulesets,
|
||
routeLookupByServerId: lookupById,
|
||
wanRuntimeByHomeId: wanRuntimeById,
|
||
}))
|
||
}
|
||
} else {
|
||
setInternetPath(null)
|
||
}
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
||
setProbesError(msg)
|
||
setLiveKpi(null)
|
||
setInternetPathError(msg)
|
||
if (!silent) {
|
||
setLiveProbes(null)
|
||
setLiveServersResolved(null)
|
||
setInternetPath(null)
|
||
}
|
||
} finally {
|
||
if (!silent) setProbesLoading(false)
|
||
if (!silent) setInternetPathLoading(false)
|
||
}
|
||
}, [apiFetch, isLive])
|
||
|
||
const fetchRecentEvents = useCallback(async (silent: boolean) => {
|
||
if (!isLive) {
|
||
setRecentEvents([])
|
||
setEventsError(null)
|
||
return
|
||
}
|
||
if (!silent) setEventsLoading(true)
|
||
try {
|
||
const rows = await listEvents(backendUrl, { limit: 8 })
|
||
setRecentEvents(rows)
|
||
setEventsError(null)
|
||
} catch (error) {
|
||
setRecentEvents([])
|
||
setEventsError(error instanceof Error ? error.message : "Не удалось загрузить события")
|
||
} finally {
|
||
if (!silent) setEventsLoading(false)
|
||
}
|
||
}, [backendUrl, isLive])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setLiveProbes(null)
|
||
setLiveServersResolved(null)
|
||
setLiveKpi(null)
|
||
setProbesError(null)
|
||
setInternetPath(null)
|
||
setInternetPathError(null)
|
||
})
|
||
return
|
||
}
|
||
let cancelled = false
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
void fetchProbes(false)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [isLive, fetchProbes])
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void fetchRecentEvents(false)
|
||
})
|
||
}, [fetchRecentEvents])
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
queueMicrotask(() => { void fetchRecentEvents(true) })
|
||
}, 20_000)
|
||
return () => clearInterval(id)
|
||
}, [fetchRecentEvents])
|
||
|
||
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
|
||
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="p-6 flex flex-col gap-6">
|
||
|
||
{/* KPI row */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<StatCard
|
||
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={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={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={dashboardKpi.alerts.value}
|
||
unit={dashboardKpi.alerts.unit}
|
||
delta={dashboardKpi.alerts.delta}
|
||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||
spark={dashboardKpi.alerts.spark}
|
||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||
/>
|
||
</div>
|
||
|
||
{/* Latency chart + Events */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||
<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">
|
||
{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>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">Последние события</CardTitle>
|
||
<Link
|
||
href="/alerts"
|
||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
|
||
>
|
||
Все →
|
||
</Link>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="divide-y divide-border">
|
||
{eventsLoading && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
|
||
)}
|
||
{eventsError && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-destructive">{eventsError}</div>
|
||
)}
|
||
{!eventsLoading && !eventsError && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-muted-foreground">Событий пока нет.</div>
|
||
)}
|
||
{recentEvents.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.level === "critical" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||
{e.level === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||
{e.level === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||
</div>
|
||
<div>
|
||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-muted-foreground">{formatEventAge(e.createdAt)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Bandwidth + Server status */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Пропускная способность</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
|
||
</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-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-3">
|
||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<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 truncate" title={serverStatusModel.subtitle}>
|
||
{serverStatusModel.subtitle}
|
||
</p>
|
||
</div>
|
||
<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">
|
||
{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 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} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4">
|
||
{internetPathError && isLive && (
|
||
<div className="text-sm text-destructive">{internetPathError}</div>
|
||
)}
|
||
{internetPathLoading && isLive && !internetPath && (
|
||
<div className="h-24 rounded-md bg-muted/40 animate-pulse" />
|
||
)}
|
||
{(!isLive || internetPath || !internetPathLoading) && (
|
||
<InternetPathMapCard model={internetPath} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Ping probes table */}
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||
<p className={cn(
|
||
"text-sm mt-0.5",
|
||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
||
)}
|
||
>
|
||
{probesSubtitle}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/uptime"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||
>
|
||
Открыть монитор →
|
||
</Link>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="overflow-x-auto">
|
||
<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 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>
|
||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{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 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">
|
||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||
</span>
|
||
</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"}`}>
|
||
{formatLossPct(p.loss)}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||
</td>
|
||
</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>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|