Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdeb97d841 | ||
|
|
36c5305db7 |
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
|
|||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
|
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
@@ -243,7 +244,7 @@ function CertPartReference() {
|
|||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="RouterOS 7 · /certificate — справка CLI"
|
title="RouterOS 7 · /certificate — справка CLI"
|
||||||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||||
contentClassName="px-5 py-4"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
|
|
||||||
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||||
|
|
||||||
{liveReady && (
|
{liveReady && (
|
||||||
<CertPartAcmeSettings
|
<CertPartAcmeSettings
|
||||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||||
|
|||||||
+260
-851
File diff suppressed because it is too large
Load Diff
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
|
|||||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
disabled={!draftCertRenewEnabled}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stillOn = await getCertificateRenewSettings()
|
||||||
|
if (!stillOn.enabled) {
|
||||||
|
item.action = "skipped"
|
||||||
|
item.message = "Автообновление выключено"
|
||||||
|
snapshot.skippedTargets += 1
|
||||||
|
snapshot.targets?.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const jobId = randomUUID()
|
const jobId = randomUUID()
|
||||||
await createIssueJobRecord({
|
await createIssueJobRecord({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
getCertificateRenewSettings,
|
||||||
|
putCertificateRenewSettings,
|
||||||
|
} from "@/shared/api/certificates"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автообновление сертификатов через MM (ACME DNS-01).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function CertificateRenewSettingsPanel({
|
||||||
|
backendUrl,
|
||||||
|
liveReady,
|
||||||
|
}: {
|
||||||
|
backendUrl: string
|
||||||
|
liveReady: boolean
|
||||||
|
}) {
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [intervalDraft, setIntervalDraft] = useState("21600")
|
||||||
|
const [daysDraft, setDaysDraft] = useState("30")
|
||||||
|
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [toggleBusy, setToggleBusy] = useState(false)
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!liveReady) return
|
||||||
|
try {
|
||||||
|
const s = await getCertificateRenewSettings(backendUrl)
|
||||||
|
setEnabled(s.enabled)
|
||||||
|
setIntervalDraft(String(s.intervalSec))
|
||||||
|
setDaysDraft(String(s.renewBeforeDays))
|
||||||
|
setLastCollectedAt(s.lastCollectedAt ?? null)
|
||||||
|
setLastError(s.lastError ?? null)
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (e) {
|
||||||
|
setLoaded(true)
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления")
|
||||||
|
}
|
||||||
|
}, [backendUrl, liveReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function handleEnabledChange(next: boolean) {
|
||||||
|
if (!liveReady || toggleBusy) return
|
||||||
|
const prev = enabled
|
||||||
|
setEnabled(next)
|
||||||
|
setToggleBusy(true)
|
||||||
|
try {
|
||||||
|
const saved = await putCertificateRenewSettings(backendUrl, { enabled: next })
|
||||||
|
setEnabled(saved.enabled)
|
||||||
|
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
|
||||||
|
} catch (e) {
|
||||||
|
setEnabled(prev)
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
} finally {
|
||||||
|
setToggleBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSchedule() {
|
||||||
|
if (!liveReady || saveBusy) return
|
||||||
|
const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600)
|
||||||
|
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30))
|
||||||
|
setSaveBusy(true)
|
||||||
|
try {
|
||||||
|
const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays })
|
||||||
|
setIntervalDraft(String(saved.intervalSec))
|
||||||
|
setDaysDraft(String(saved.renewBeforeDays))
|
||||||
|
toast.success("Расписание автообновления сохранено")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание")
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpsPanel
|
||||||
|
title="Автообновление через MikrotikManager"
|
||||||
|
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
|
||||||
|
headerRight={
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||||
|
>
|
||||||
|
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!liveReady ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Нет подключения к API</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : enabled ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let's Encrypt на
|
||||||
|
устройстве для тех же имён лучше не включать одновременно.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="info">
|
||||||
|
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
|
||||||
|
остаются доступны.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={intervalDraft}
|
||||||
|
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||||
|
disabled={!liveReady || saveBusy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={daysDraft}
|
||||||
|
onChange={(e) => setDaysDraft(e.target.value)}
|
||||||
|
disabled={!liveReady || saveBusy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||||
|
Сохранить расписание
|
||||||
|
</Button>
|
||||||
|
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
|
||||||
|
Журнал планировщика →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastCollectedAt || lastError ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Последний прогон:{" "}
|
||||||
|
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
|
||||||
|
{lastError ? ` · ошибка: ${lastError}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</OpsPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from "@/components/reui/timeline"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { EventItem } from "@mmapp/contracts/events"
|
||||||
|
|
||||||
|
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}д`
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_DOT: Record<EventItem["level"], string> = {
|
||||||
|
critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive",
|
||||||
|
warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning",
|
||||||
|
info: "border-info bg-info/20 group-data-completed/timeline-item:border-info",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact activity timeline.
|
||||||
|
* Preview: https://reui.io/preview/base/timeline-3
|
||||||
|
* Docs: https://reui.io/docs/components/base/timeline
|
||||||
|
*/
|
||||||
|
export function DashboardEventsTimeline({
|
||||||
|
events,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
events: EventItem[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: string | null
|
||||||
|
}) {
|
||||||
|
if (loading && events.length === 0) {
|
||||||
|
return <p className="text-muted-foreground px-5 py-6 text-sm">Загрузка событий…</p>
|
||||||
|
}
|
||||||
|
if (error && events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>События недоступны</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground px-5 py-6 text-sm">
|
||||||
|
Событий пока нет.{" "}
|
||||||
|
<Link href="/alerts" className="underline underline-offset-2">
|
||||||
|
Оповещения
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline defaultValue={events.length} className="px-5 py-4">
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<TimelineItem key={event.id} step={index + 1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineDate>{formatEventAge(event.createdAt)}</TimelineDate>
|
||||||
|
<TimelineTitle className="text-[13px] leading-tight">{event.title}</TimelineTitle>
|
||||||
|
<TimelineIndicator className={cn(LEVEL_DOT[event.level])} />
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent className="text-xs leading-snug">{event.message}</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||||
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
|
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
|
||||||
@@ -167,7 +165,7 @@ function ServerNode({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) {
|
export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel | null }) {
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
@@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpsPanel
|
<div className="flex flex-col gap-3">
|
||||||
title="Internet path map"
|
|
||||||
description="Основной и текущий путь трафика HomeRouter → Internet"
|
|
||||||
headerRight={
|
|
||||||
<StatusBadge
|
|
||||||
status={
|
|
||||||
model?.pathState === "healthy"
|
|
||||||
? "online"
|
|
||||||
: model?.pathState === "failover"
|
|
||||||
? "degraded"
|
|
||||||
: "offline"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
contentClassName="px-5 pb-4"
|
|
||||||
>
|
|
||||||
{!model && (
|
{!model && (
|
||||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||||
Недостаточно данных для построения маршрута
|
Недостаточно данных для построения маршрута
|
||||||
@@ -412,6 +395,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</OpsPanel>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||||
|
import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map"
|
||||||
|
import { InternetPathSummary } from "@/components/dashboard/internet-path-summary"
|
||||||
|
|
||||||
|
const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open"
|
||||||
|
|
||||||
|
function readMapOpen(): boolean {
|
||||||
|
if (typeof window === "undefined") return true
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PATH_MAP_OPEN_LS)
|
||||||
|
if (raw === "0") return false
|
||||||
|
if (raw === "1") return true
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InternetPathPanel({
|
||||||
|
model,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
model: InternetPathViewModel | null
|
||||||
|
loading?: boolean
|
||||||
|
error?: string | null
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setOpen(readMapOpen())
|
||||||
|
setHydrated(true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
setOpen(next)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0")
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpsPanel
|
||||||
|
title="Internet path"
|
||||||
|
description="Home → WAN → JH → Exit"
|
||||||
|
headerRight={
|
||||||
|
<Link href="/network-map" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Карта сети →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
contentClassName="flex flex-col gap-3 px-5 pb-4"
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Не удалось загрузить путь</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{loading && !model ? (
|
||||||
|
<div className="h-20 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
) : (
|
||||||
|
<InternetPathSummary model={model} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapsible open={hydrated ? open : true} onOpenChange={handleOpenChange}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 w-fit gap-1.5 text-xs")}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||||
|
{open ? "Скрыть карту" : "Показать карту"}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<InternetPathMapCanvas model={model} />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</OpsPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||||
|
import type { ServerStatus } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus {
|
||||||
|
if (state === "healthy") return "online"
|
||||||
|
if (state === "failover" || state === "degraded") return "degraded"
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
function HopChip({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
icon,
|
||||||
|
iconClassName,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
name: string
|
||||||
|
country?: string
|
||||||
|
icon: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
|
||||||
|
<IconTile variant="elevated" size="sm" className={cn("shrink-0", iconClassName)} aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</IconTile>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">{label}</p>
|
||||||
|
<p className="flex items-center gap-1 truncate text-sm font-medium">
|
||||||
|
{country ? <Flag code={country} className="shrink-0" /> : null}
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact live path strip (Frame-friendly). Canvas lives separately.
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) {
|
||||||
|
if (!model) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Недостаточно данных для пути. Добавьте home-router и проверьте{" "}
|
||||||
|
<Link href="/network-map" className="underline underline-offset-2">
|
||||||
|
карту сети
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hop = model.currentHop ?? model.primaryHop
|
||||||
|
const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN"
|
||||||
|
const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—"
|
||||||
|
const ping = hop?.wanJhMetrics.pingMs
|
||||||
|
const dl = hop?.wanJhMetrics.dlMbps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={pathStateToServerStatus(model.pathState)} />
|
||||||
|
{model.pathState === "failover" ? (
|
||||||
|
<Badge variant="warning-light" size="sm">failover</Badge>
|
||||||
|
) : null}
|
||||||
|
{ping != null ? (
|
||||||
|
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||||
|
{ping} мс
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{dl != null ? (
|
||||||
|
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||||
|
{Math.round(dl)} ↓ Мбит/с
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 @3xl:flex-row @3xl:items-center">
|
||||||
|
<HopChip
|
||||||
|
label="Home"
|
||||||
|
name={model.homeRouter.name}
|
||||||
|
country={model.homeRouter.country}
|
||||||
|
icon={<HomeIcon />}
|
||||||
|
iconClassName="text-success"
|
||||||
|
/>
|
||||||
|
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||||
|
<HopChip
|
||||||
|
label="WAN"
|
||||||
|
name={`${wanName} · ${wanIsp}`}
|
||||||
|
icon={<RadioIcon />}
|
||||||
|
iconClassName="text-info"
|
||||||
|
/>
|
||||||
|
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||||
|
<HopChip
|
||||||
|
label="JH"
|
||||||
|
name={hop?.jumpHost.name ?? model.fallbackJumpHost?.name ?? "—"}
|
||||||
|
country={hop?.jumpHost.country ?? model.fallbackJumpHost?.country}
|
||||||
|
icon={<ServerIcon />}
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||||
|
<HopChip
|
||||||
|
label="Exit"
|
||||||
|
name={hop?.exitNode.name ?? model.fallbackExitNode?.name ?? "—"}
|
||||||
|
country={hop?.exitNode.country ?? model.fallbackExitNode?.country}
|
||||||
|
icon={<GlobeIcon />}
|
||||||
|
iconClassName="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{model.currentPath?.reason ?? model.primaryPath?.reason ?? "Текущий путь не определён"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sibling Frame columns for dashboard attention queue.
|
||||||
|
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export interface AttentionQueueColumn {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: LucideIcon
|
||||||
|
iconClassName?: string
|
||||||
|
count: number
|
||||||
|
countVariant?: "destructive" | "warning" | "secondary" | "destructive-light" | "warning-light"
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDescription: string
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttentionQueueProps {
|
||||||
|
columns: AttentionQueueColumn[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||||
|
|
||||||
|
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"@container grid min-w-0 items-start gap-2 @3xl:grid-cols-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const Icon = column.icon
|
||||||
|
const isEmpty = column.count === 0
|
||||||
|
return (
|
||||||
|
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
|
||||||
|
{column.count > 0 ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={column.countVariant ?? "secondary"}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{column.count}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="min-w-0">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex min-h-24 flex-col items-start justify-center gap-1 py-3">
|
||||||
|
<p className="text-sm font-medium">{column.emptyTitle}</p>
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{column.emptyDescription}
|
||||||
|
</p>
|
||||||
|
{column.emptyAction ? <div className="pt-1">{column.emptyAction}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
column.children
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet
|
|||||||
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||||
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||||
export { kpiCols } from "./kpi-cols"
|
export { kpiCols } from "./kpi-cols"
|
||||||
|
export { QuickActionGrid } from "./quick-action-grid"
|
||||||
|
export type { QuickActionItem } from "./quick-action-grid"
|
||||||
|
export { AttentionQueue } from "./attention-queue"
|
||||||
|
export type { AttentionQueueColumn } from "./attention-queue"
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { kpiCols } from "./kpi-cols"
|
||||||
|
|
||||||
|
type QuickActionBase = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
badge?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuickActionItem = QuickActionBase &
|
||||||
|
(
|
||||||
|
| { href: string; onClick?: never }
|
||||||
|
| { onClick: () => void; href?: never }
|
||||||
|
)
|
||||||
|
|
||||||
|
interface QuickActionGridProps {
|
||||||
|
actions: QuickActionItem[]
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||||
|
|
||||||
|
function resolveBadge(action: QuickActionItem): string {
|
||||||
|
if (action.badge) return action.badge
|
||||||
|
return action.onClick ? "Выполнить" : "Перейти"
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{action.icon ? (
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-10.5", action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
|
{resolveBadge(action)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(disabled?: boolean) {
|
||||||
|
return cn(
|
||||||
|
"relative isolate flex h-full flex-col transition-colors",
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export function QuickActionGrid({
|
||||||
|
actions,
|
||||||
|
title = "Быстрые действия",
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: QuickActionGridProps) {
|
||||||
|
if (actions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn("@container w-full", className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<FrameHeader>
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
)}
|
||||||
|
<div className={cn("grid gap-2", kpiCols(actions.length))}>
|
||||||
|
{actions.map((action) => {
|
||||||
|
const label = `${action.title}: ${action.description}`
|
||||||
|
|
||||||
|
if ("href" in action && action.href) {
|
||||||
|
return (
|
||||||
|
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
|
||||||
|
{action.disabled ? (
|
||||||
|
<div aria-disabled aria-label={label}>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
<Link
|
||||||
|
href={action.href}
|
||||||
|
className="absolute inset-0 z-20 focus-visible:outline-none"
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = action.onClick
|
||||||
|
const onActivate = () => {
|
||||||
|
if (action.disabled || !onClick) return
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className={panelClassName(action.disabled)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={action.disabled ? -1 : 0}
|
||||||
|
aria-disabled={action.disabled || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onActivate}
|
||||||
|
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,63 +70,72 @@ export function TrafficRxTxChart({
|
|||||||
rx,
|
rx,
|
||||||
tx,
|
tx,
|
||||||
range = "1h",
|
range = "1h",
|
||||||
|
embedded = false,
|
||||||
}: {
|
}: {
|
||||||
rx: number[]
|
rx: number[]
|
||||||
tx: number[]
|
tx: number[]
|
||||||
range?: string
|
range?: string
|
||||||
|
/** Skip outer Frame when already inside OpsPanel / Frame. */
|
||||||
|
embedded?: boolean
|
||||||
}) {
|
}) {
|
||||||
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||||
const data = toChartData(rx, tx, rangeMinutes)
|
const data = toChartData(rx, tx, rangeMinutes)
|
||||||
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
||||||
|
|
||||||
|
const chart = (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="4 8"
|
||||||
|
vertical={false}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickMargin={10}
|
||||||
|
interval={tickEvery - 1}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||||
|
tickMargin={8}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<CustomTooltip />} />
|
||||||
|
<Line
|
||||||
|
dataKey="rx"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--chart-rx)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="tx"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--chart-tx)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="mb-1 flex items-center justify-center gap-6">
|
||||||
|
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||||
|
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return chart
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className="w-full">
|
<Frame className="w-full">
|
||||||
<FramePanel className="flex flex-col gap-6">
|
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
|
||||||
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
|
||||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="4 8"
|
|
||||||
vertical={false}
|
|
||||||
stroke="var(--border)"
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
tickMargin={10}
|
|
||||||
interval={tickEvery - 1}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
tickFormatter={(v: number) => fmtRate(Number(v))}
|
|
||||||
tickMargin={8}
|
|
||||||
width={72}
|
|
||||||
/>
|
|
||||||
<ChartTooltip content={<CustomTooltip />} />
|
|
||||||
<Line
|
|
||||||
dataKey="rx"
|
|
||||||
type="monotone"
|
|
||||||
stroke="var(--chart-rx)"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
dataKey="tx"
|
|
||||||
type="monotone"
|
|
||||||
stroke="var(--chart-tx)"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
|
||||||
<div className="mb-1 flex items-center justify-center gap-6">
|
|
||||||
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
|
||||||
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import {
|
||||||
|
dashLatency,
|
||||||
|
greTunnels as mockGreTunnels,
|
||||||
|
pingProbes,
|
||||||
|
servers as mockServers,
|
||||||
|
traffic as mockTraffic,
|
||||||
|
vxlanTunnels as mockVxlan,
|
||||||
|
type GreTunnel,
|
||||||
|
type PingProbe,
|
||||||
|
type Server,
|
||||||
|
type ServerStatus,
|
||||||
|
type ServerType,
|
||||||
|
} from "@/lib/data"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { listEvents } from "@/shared/api/events"
|
||||||
|
import { listWireGuard } from "@/shared/api/wireguard"
|
||||||
|
import type { EventItem } from "@mmapp/contracts/events"
|
||||||
|
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"
|
||||||
|
|
||||||
|
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
||||||
|
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
|
||||||
|
|
||||||
|
function makeApiFetch(backendUrl: string) {
|
||||||
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestJson<T>(backendUrl, path, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficServerRow {
|
||||||
|
id: string
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
rxSeries: number[]
|
||||||
|
txSeries: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayKind = "gre" | "wg" | "vxlan"
|
||||||
|
|
||||||
|
export interface OverlayItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: OverlayKind
|
||||||
|
href: string
|
||||||
|
status: "up" | "down" | "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttentionRow {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
href: string
|
||||||
|
tone: "destructive" | "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatencyBlock =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "empty"; message: string }
|
||||||
|
| { kind: "mock"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
||||||
|
| { kind: "live"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
||||||
|
|
||||||
|
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 sumSeries(rows: TrafficServerRow[], key: "rxSeries" | "txSeries"): number[] {
|
||||||
|
const len = Math.max(60, ...rows.map((r) => r[key].length), 0)
|
||||||
|
const out = Array.from({ length: len }, () => 0)
|
||||||
|
for (const row of rows) {
|
||||||
|
const series = row[key]
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
out[i] += series[i] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockOverlayItems(): OverlayItem[] {
|
||||||
|
const gre: OverlayItem[] = mockGreTunnels.map((t) => ({
|
||||||
|
id: `gre-${t.id}`,
|
||||||
|
name: t.name,
|
||||||
|
kind: "gre",
|
||||||
|
href: "/gre",
|
||||||
|
status: t.status,
|
||||||
|
}))
|
||||||
|
const wg: OverlayItem[] = mockServers.flatMap((s) =>
|
||||||
|
(s.wireGuardIfaces ?? []).map((iface) => ({
|
||||||
|
id: `wg-${iface.id}`,
|
||||||
|
name: iface.name,
|
||||||
|
kind: "wg" as const,
|
||||||
|
href: "/wireguard",
|
||||||
|
status: iface.status,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const vx: OverlayItem[] = mockVxlan.map((t) => ({
|
||||||
|
id: `vx-${t.id}`,
|
||||||
|
name: t.name,
|
||||||
|
kind: "vxlan",
|
||||||
|
href: "/vxlan",
|
||||||
|
status: t.status,
|
||||||
|
}))
|
||||||
|
return [...gre, ...wg, ...vx]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardLive() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
const isLive = prefsHydrated && mode === "live"
|
||||||
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
|
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
||||||
|
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
|
||||||
|
const [bgp, setBgp] = useState<{ prefixSum: number; establishedCount: number } | null>(null)
|
||||||
|
const [trafficSeries, setTrafficSeries] = useState<{
|
||||||
|
rx: number[]
|
||||||
|
tx: number[]
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
} | null>(null)
|
||||||
|
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||||||
|
const [eventsError, setEventsError] = useState<string | null>(null)
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false)
|
||||||
|
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
|
||||||
|
const [internetPathLoading, setInternetPathLoading] = useState(false)
|
||||||
|
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mockDashEpoch, setMockDashEpoch] = useState(0)
|
||||||
|
|
||||||
|
const fetchSnapshot = useCallback(async (silent: boolean) => {
|
||||||
|
if (!isLive) return
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(true)
|
||||||
|
setInternetPathLoading(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([
|
||||||
|
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
|
||||||
|
apiFetch<BackendServerRow[]>("/api/servers"),
|
||||||
|
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"),
|
||||||
|
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"),
|
||||||
|
listWireGuard(backendUrl),
|
||||||
|
apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"),
|
||||||
|
])
|
||||||
|
|
||||||
|
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
|
||||||
|
if (hardFail) {
|
||||||
|
const reason = overviewRes.reason
|
||||||
|
setError(reason instanceof Error ? reason.message : "Не удалось загрузить дашборд")
|
||||||
|
} else {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
setInternetPathError(null)
|
||||||
|
|
||||||
|
if (overviewRes.status === "fulfilled") {
|
||||||
|
setLiveProbes(overviewRes.value.probes)
|
||||||
|
} else {
|
||||||
|
setLiveProbes([])
|
||||||
|
}
|
||||||
|
|
||||||
|
let serversMapped: Server[] = []
|
||||||
|
if (serversRes.status === "fulfilled") {
|
||||||
|
serversMapped = serversRes.value.map(mapBackendToServer)
|
||||||
|
setLiveServers(serversMapped)
|
||||||
|
} else {
|
||||||
|
setLiveServers([])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBgp({ prefixSum, establishedCount })
|
||||||
|
} else {
|
||||||
|
setBgp(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const greItems: OverlayItem[] =
|
||||||
|
greRes.status === "fulfilled"
|
||||||
|
? (greRes.value.tunnels ?? []).map((t) => ({
|
||||||
|
id: `gre-${t.id}`,
|
||||||
|
name: t.name,
|
||||||
|
kind: "gre" as const,
|
||||||
|
href: "/gre",
|
||||||
|
status: t.status,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
const wgItems: OverlayItem[] =
|
||||||
|
wgRes.status === "fulfilled"
|
||||||
|
? wgRes.value.interfaces.map((iface) => ({
|
||||||
|
id: `wg-${iface.id}`,
|
||||||
|
name: iface.name,
|
||||||
|
kind: "wg" as const,
|
||||||
|
href: "/wireguard",
|
||||||
|
status: iface.status,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
setOverlayItems([...greItems, ...wgItems])
|
||||||
|
|
||||||
|
if (trafficRes.status === "fulfilled") {
|
||||||
|
const rows = trafficRes.value.servers ?? []
|
||||||
|
setTrafficSeries({
|
||||||
|
rx: sumSeries(rows, "rxSeries"),
|
||||||
|
tx: sumSeries(rows, "txSeries"),
|
||||||
|
rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0),
|
||||||
|
txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTrafficSeries(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const greMapped =
|
||||||
|
greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : []
|
||||||
|
|
||||||
|
if (serversMapped.length > 0) {
|
||||||
|
const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null
|
||||||
|
if (snap) {
|
||||||
|
setInternetPath(
|
||||||
|
buildDashboardInternetPath({
|
||||||
|
servers: snap.servers.map(mapBackendToServer),
|
||||||
|
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 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: greMapped,
|
||||||
|
probes: speedRes.probes ?? [],
|
||||||
|
filtersRulesets: filterRulesets,
|
||||||
|
routeLookupByServerId: lookupById,
|
||||||
|
wanRuntimeByHomeId: wanRuntimeById,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setInternetPath(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд"
|
||||||
|
setInternetPathError(msg)
|
||||||
|
setInternetPath(null)
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(false)
|
||||||
|
setInternetPathLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [apiFetch, backendUrl, isLive])
|
||||||
|
|
||||||
|
const fetchRecentEvents = useCallback(async (silent: boolean) => {
|
||||||
|
if (!isLive) {
|
||||||
|
setRecentEvents([])
|
||||||
|
setEventsError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!silent) setEventsLoading(true)
|
||||||
|
try {
|
||||||
|
const rows = await listEvents(backendUrl, { limit: 6 })
|
||||||
|
setRecentEvents(rows)
|
||||||
|
setEventsError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setRecentEvents([])
|
||||||
|
setEventsError(err instanceof Error ? err.message : "Не удалось загрузить события")
|
||||||
|
} finally {
|
||||||
|
if (!silent) setEventsLoading(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, isLive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveProbes(null)
|
||||||
|
setLiveServers(null)
|
||||||
|
setOverlayItems(null)
|
||||||
|
setBgp(null)
|
||||||
|
setTrafficSeries(null)
|
||||||
|
setError(null)
|
||||||
|
setInternetPath(null)
|
||||||
|
setInternetPathError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
void fetchSnapshot(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isLive, fetchSnapshot])
|
||||||
|
|
||||||
|
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 fetchSnapshot(true)
|
||||||
|
})
|
||||||
|
}, 60_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [isLive, fetchSnapshot])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) return
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void fetchSnapshot(true)
|
||||||
|
})
|
||||||
|
}, [pathname, isLive, fetchSnapshot])
|
||||||
|
|
||||||
|
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 fetchSnapshot(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, fetchSnapshot])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => setMockDashEpoch((x) => x + 1))
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
const servers = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
|
if (!isLive) return mockServers
|
||||||
|
return liveServers ?? []
|
||||||
|
}, [prefsHydrated, isLive, liveServers])
|
||||||
|
|
||||||
|
const mockActiveProbes = useMemo(() => {
|
||||||
|
void mockDashEpoch
|
||||||
|
const stars = readMockDashboardStarIds()
|
||||||
|
return pingProbes.filter((p) => p.enabled && stars.has(p.id))
|
||||||
|
}, [mockDashEpoch])
|
||||||
|
|
||||||
|
const activeProbes = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
|
if (!isLive) return mockActiveProbes
|
||||||
|
if (liveProbes === null) return []
|
||||||
|
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||||
|
}, [prefsHydrated, isLive, liveProbes, mockActiveProbes])
|
||||||
|
|
||||||
|
const enabledProbes = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
|
if (!isLive) return pingProbes.filter((p) => p.enabled)
|
||||||
|
return liveProbes?.filter((p) => p.enabled) ?? []
|
||||||
|
}, [prefsHydrated, isLive, liveProbes])
|
||||||
|
|
||||||
|
const overlay = useMemo(() => {
|
||||||
|
const items = !prefsHydrated ? [] : isLive ? (overlayItems ?? []) : mockOverlayItems()
|
||||||
|
const up = items.filter((i) => i.status === "up").length
|
||||||
|
const down = items.filter((i) => i.status !== "up").length
|
||||||
|
return { items, total: items.length, up, down }
|
||||||
|
}, [prefsHydrated, isLive, overlayItems])
|
||||||
|
|
||||||
|
const traffic = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return null
|
||||||
|
if (!isLive) {
|
||||||
|
return {
|
||||||
|
rx: mockTraffic.rx,
|
||||||
|
tx: mockTraffic.tx,
|
||||||
|
rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0,
|
||||||
|
txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0,
|
||||||
|
demo: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!trafficSeries) return null
|
||||||
|
return { ...trafficSeries, demo: false }
|
||||||
|
}, [prefsHydrated, isLive, trafficSeries])
|
||||||
|
|
||||||
|
const latency: LatencyBlock = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return { kind: "loading" }
|
||||||
|
if (!isLive) {
|
||||||
|
return {
|
||||||
|
kind: "mock",
|
||||||
|
series: dashLatency,
|
||||||
|
subtitle: "Последние 60 минут · демо",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (liveProbes === null && loading) return { kind: "loading" }
|
||||||
|
if (!liveProbes?.length) {
|
||||||
|
return {
|
||||||
|
kind: "empty",
|
||||||
|
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { series, labels } = buildLatencySeriesByProbeSource(liveProbes, liveServers ?? [], {
|
||||||
|
maxServers: 8,
|
||||||
|
points: 60,
|
||||||
|
})
|
||||||
|
if (Object.keys(series).length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "empty",
|
||||||
|
message: "Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "live",
|
||||||
|
series,
|
||||||
|
labels,
|
||||||
|
subtitle: "Средний RTT · 1 ч · до 8 узлов",
|
||||||
|
}
|
||||||
|
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
|
||||||
|
|
||||||
|
const attentionServers: AttentionRow[] = useMemo(() => {
|
||||||
|
return servers
|
||||||
|
.filter((s) => s.enabled && s.status !== "online")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.name,
|
||||||
|
hint: s.status === "degraded" ? "degraded" : "offline",
|
||||||
|
href: "/servers",
|
||||||
|
tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
||||||
|
}))
|
||||||
|
}, [servers])
|
||||||
|
|
||||||
|
const attentionOverlay: AttentionRow[] = useMemo(() => {
|
||||||
|
return overlay.items
|
||||||
|
.filter((i) => i.status !== "up")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.name,
|
||||||
|
hint: i.status === "degraded" ? "degraded" : "down",
|
||||||
|
href: i.href,
|
||||||
|
tone: i.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
||||||
|
}))
|
||||||
|
}, [overlay.items])
|
||||||
|
|
||||||
|
const attentionProbes: AttentionRow[] = useMemo(() => {
|
||||||
|
return enabledProbes
|
||||||
|
.filter((p) => p.status === "down" || p.status === "warn")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.name,
|
||||||
|
hint: p.status === "warn" ? "warn" : "down",
|
||||||
|
href: "/uptime",
|
||||||
|
tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const),
|
||||||
|
}))
|
||||||
|
}, [enabledProbes])
|
||||||
|
|
||||||
|
const onlineCount = servers.filter((s) => s.status === "online").length
|
||||||
|
const probeDown = enabledProbes.filter((p) => p.status === "down").length
|
||||||
|
const probeWarn = enabledProbes.filter((p) => p.status === "warn").length
|
||||||
|
const dataPending = isLive && !error && (liveServers === null || liveProbes === null)
|
||||||
|
const kpiLoading = !prefsHydrated || dataPending
|
||||||
|
|
||||||
|
const probesSubtitle = !prefsHydrated
|
||||||
|
? "Загрузка…"
|
||||||
|
: !isLive
|
||||||
|
? mockActiveProbes.length > 0
|
||||||
|
? `${mockActiveProbes.length} на дашборде · демо`
|
||||||
|
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||||
|
: error && liveProbes === null
|
||||||
|
? error
|
||||||
|
: activeProbes.length > 0
|
||||||
|
? `${activeProbes.length} на дашборде · 1 ч`
|
||||||
|
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefsHydrated,
|
||||||
|
isLive,
|
||||||
|
loading: kpiLoading,
|
||||||
|
error,
|
||||||
|
retry: () => {
|
||||||
|
void fetchSnapshot(false)
|
||||||
|
void fetchRecentEvents(false)
|
||||||
|
},
|
||||||
|
servers,
|
||||||
|
activeProbes,
|
||||||
|
overlay,
|
||||||
|
bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 },
|
||||||
|
traffic,
|
||||||
|
onlineCount,
|
||||||
|
totalServers: servers.length,
|
||||||
|
probeDown,
|
||||||
|
probeWarn,
|
||||||
|
latency,
|
||||||
|
recentEvents,
|
||||||
|
eventsLoading,
|
||||||
|
eventsError,
|
||||||
|
internetPath,
|
||||||
|
internetPathLoading: isLive && internetPathLoading && !internetPath,
|
||||||
|
internetPathError,
|
||||||
|
attentionServers,
|
||||||
|
attentionOverlay,
|
||||||
|
attentionProbes,
|
||||||
|
probesSubtitle,
|
||||||
|
probesLoading: isLive && loading && liveProbes === null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
|||||||
gre_bgp:
|
gre_bgp:
|
||||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
||||||
certificates_renew:
|
certificates_renew:
|
||||||
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
|
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
|
||||||
backups:
|
backups:
|
||||||
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
||||||
alert_engine:
|
alert_engine:
|
||||||
|
|||||||
Reference in New Issue
Block a user