Compare commits

..
3 Commits
Author SHA1 Message Date
DenozordecandCursor a8f2055c77 feat(netflow): расширить каталог ASN популярных сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
В аналитике потока видны сети со своим AS (Apple, Steam, VK, Epic и другие), а не только Cloudflare/AWS. Holder и порты Steam не перебивают Cloudflare CIDR.

Co-authored-by: Cursor <[email protected]>
2026-09-09 13:18:58 +07:00
DenozordecandCursor cdeb97d841 feat(certificates): добавить отключение автообновления на странице сертификатов
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m7s
Docker images / frontend-image (push) Successful in 3m19s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Co-authored-by: Cursor <[email protected]>
2026-09-08 16:21:50 +07:00
DenozordecandCursor 36c5305db7 feat(dashboard): собрать ops-дашборд на ReUI Frame с живыми данными
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-08 15:13:27 +07:00
20 changed files with 2098 additions and 980 deletions
+4 -1
View File
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
@@ -243,7 +244,7 @@ function CertPartReference() {
return (
<OpsPanel
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"
>
<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} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
{liveReady && (
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
disabled={!draftCertRenewEnabled}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
</p>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
continue
}
const stillOn = await getCertificateRenewSettings()
if (!stillOn.enabled) {
item.action = "skipped"
item.message = "Автообновление выключено"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
continue
}
const jobId = randomUUID()
await createIssueJobRecord({
id: jobId,
@@ -1,11 +1,14 @@
import assert from "node:assert/strict"
import {
brandByAsn,
brandByHolder,
countryFromHolder,
isSteamGamePort,
lookupBrand,
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
resolveFlowBrand,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -39,4 +42,35 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
assert.equal(brandByAsn(36459)?.service, "GitHub")
assert.equal(brandByAsn(395701)?.service, "Epic")
assert.equal(brandByAsn(6507)?.service, "Riot")
assert.equal(brandByAsn(33353)?.service, "PlayStation")
assert.equal(brandByAsn(14061)?.service, "DigitalOcean")
assert.equal(brandByAsn(24940)?.service, "Hetzner")
assert.equal(brandByAsn(8403)?.service, "Spotify")
assert.equal(brandByAsn(13414)?.service, "X")
assert.equal(brandByAsn(47541)?.service, "VK")
assert.equal(brandByAsn(47764)?.service, "VK")
assert.equal(brandByAsn(30103)?.service, "Zoom")
assert.equal(brandByAsn(19281)?.service, "Quad9")
assert.equal(brandByAsn(9059)?.service, "AWS")
assert.equal(brandByAsn(396982)?.service, "Google")
assert.equal(brandByAsn(400645)?.service, "ChatGPT")
assert.equal(brandByHolder("VALVE-CORPORATION")?.service, "Steam")
assert.equal(brandByHolder("OpenAI, LLC")?.service, "ChatGPT")
assert.equal(brandByHolder("YouTube LLC")?.service, "YouTube")
assert.equal(brandByHolder("AMAZON-AES - Amazon.com, Inc."), null)
assert.equal(isSteamGamePort(17, 27015, 50000), true)
assert.equal(isSteamGamePort(6, 443, 50000), false)
assert.equal(resolveFlowBrand("104.18.35.51", 32590, "VALVE-CORPORATION", 6, 443, 1)?.service, "Cloudflare")
assert.equal(resolveFlowBrand("203.0.113.9", 32590, "", 17, 27015, 50000)?.service, "Steam")
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
console.log("traffic-flow-brands.test.ts: ok")
+208 -47
View File
@@ -7,59 +7,162 @@ export interface BrandHit {
category: string
}
const CDN = { category: "CDN" } as const
const WEB = { category: "Веб" } as const
const VIDEO = { category: "Видео / стриминг" } as const
const GAMES = { category: "Игры" } as const
const VOICE = { category: "Голос" } as const
const AI = { category: "ИИ" } as const
const DNS = { category: "DNS" } as const
const CLOUDFLARE: BrandHit = { service: "Cloudflare", ...CDN }
const FASTLY: BrandHit = { service: "Fastly", ...CDN }
const AKAMAI: BrandHit = { service: "Akamai", ...CDN }
const AWS: BrandHit = { service: "AWS", ...CDN }
const MICROSOFT: BrandHit = { service: "Microsoft", ...CDN }
const YANDEX: BrandHit = { service: "Yandex", ...CDN }
const APPLE: BrandHit = { service: "Apple", ...CDN }
const DIGITALOCEAN: BrandHit = { service: "DigitalOcean", ...CDN }
const HETZNER: BrandHit = { service: "Hetzner", ...CDN }
const OVH: BrandHit = { service: "OVH", ...CDN }
const ORACLE: BrandHit = { service: "Oracle", ...CDN }
const LINODE: BrandHit = { service: "Linode", ...CDN }
const VULTR: BrandHit = { service: "Vultr", ...CDN }
const SCALEWAY: BrandHit = { service: "Scaleway", ...CDN }
const IBM_CLOUD: BrandHit = { service: "IBM Cloud", ...CDN }
const ALIBABA: BrandHit = { service: "Alibaba", ...CDN }
const TENCENT: BrandHit = { service: "Tencent", ...CDN }
const GCORE: BrandHit = { service: "G-Core", ...CDN }
const CDN77: BrandHit = { service: "CDN77", ...CDN }
const SELECTEL: BrandHit = { service: "Selectel", ...CDN }
const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
const BEGET: BrandHit = { service: "Beget", ...CDN }
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
const META: BrandHit = { service: "Meta", ...CDN }
const GOOGLE: BrandHit = { service: "Google", ...WEB }
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
const GITLAB: BrandHit = { service: "GitLab", ...WEB }
const X: BrandHit = { service: "X", ...WEB }
const LINKEDIN: BrandHit = { service: "LinkedIn", ...WEB }
const VK: BrandHit = { service: "VK", ...WEB }
const REDDIT: BrandHit = { service: "Reddit", ...WEB }
const DROPBOX: BrandHit = { service: "Dropbox", ...WEB }
const SNAP: BrandHit = { service: "Snap", ...WEB }
const WIKIPEDIA: BrandHit = { service: "Wikipedia", ...WEB }
const PAYPAL: BrandHit = { service: "PayPal", ...WEB }
const SALESFORCE: BrandHit = { service: "Salesforce", ...WEB }
const YOUTUBE: BrandHit = { service: "YouTube", ...VIDEO }
const NETFLIX: BrandHit = { service: "Netflix", ...VIDEO }
const TWITCH: BrandHit = { service: "Twitch", ...VIDEO }
const TIKTOK: BrandHit = { service: "TikTok", ...VIDEO }
const SPOTIFY: BrandHit = { service: "Spotify", ...VIDEO }
const STEAM: BrandHit = { service: "Steam", ...GAMES }
const BLIZZARD: BrandHit = { service: "Blizzard", ...GAMES }
const EPIC: BrandHit = { service: "Epic", ...GAMES }
const RIOT: BrandHit = { service: "Riot", ...GAMES }
const PLAYSTATION: BrandHit = { service: "PlayStation", ...GAMES }
const ROBLOX: BrandHit = { service: "Roblox", ...GAMES }
const UBISOFT: BrandHit = { service: "Ubisoft", ...GAMES }
const DISCORD: BrandHit = { service: "Discord", ...VOICE }
const TELEGRAM: BrandHit = { service: "Telegram", ...VOICE }
const ZOOM: BrandHit = { service: "Zoom", ...VOICE }
const CHATGPT: BrandHit = { service: "ChatGPT", ...AI }
const QUAD9: BrandHit = { service: "Quad9", ...DNS }
const OPENDNS: BrandHit = { service: "OpenDNS", ...DNS }
function brandEntries(hit: BrandHit, asns: number[]): Array<[number, BrandHit]> {
return asns.map((asn) => [asn, hit])
}
function hqEntries(cc: string, asns: number[]): Array<[number, string]> {
return asns.map((asn) => [asn, cc])
}
const ASN_BRANDS = new Map<number, BrandHit>([
[13335, { service: "Cloudflare", category: "CDN" }],
[209242, { service: "Cloudflare", category: "CDN" }],
[54113, { service: "Fastly", category: "CDN" }],
[20940, { service: "Akamai", category: "CDN" }],
[16509, { service: "AWS", category: "CDN" }],
[14618, { service: "AWS", category: "CDN" }],
[8075, { service: "Microsoft", category: "CDN" }],
[13238, { service: "Yandex", category: "CDN" }],
[32590, { service: "Steam", category: "Игры" }],
[57976, { service: "Blizzard", category: "Игры" }],
[2906, { service: "Netflix", category: "Видео / стриминг" }],
[40027, { service: "Netflix", category: "Видео / стриминг" }],
[15169, { service: "Google", category: "Веб" }],
[36040, { service: "YouTube", category: "Видео / стриминг" }],
[46489, { service: "Twitch", category: "Видео / стриминг" }],
[401115, { service: "ChatGPT", category: "ИИ" }],
[49544, { service: "Discord", category: "Голос" }],
[62041, { service: "Telegram", category: "Голос" }],
[59930, { service: "Telegram", category: "Голос" }],
[211157, { service: "Telegram", category: "Голос" }],
[32934, { service: "Meta", category: "CDN" }],
[396986, { service: "TikTok", category: "Видео / стриминг" }],
...brandEntries(CLOUDFLARE, [13335, 209242]),
...brandEntries(FASTLY, [54113]),
...brandEntries(AKAMAI, [20940, 16625, 32787, 35994, 16702, 24319]),
...brandEntries(AWS, [16509, 14618, 8987, 7224, 9059]),
...brandEntries(MICROSOFT, [8075, 8068, 8069, 8070]),
...brandEntries(YANDEX, [13238]),
...brandEntries(APPLE, [714, 6185]),
...brandEntries(DIGITALOCEAN, [14061]),
...brandEntries(HETZNER, [24940, 213230]),
...brandEntries(OVH, [16276]),
...brandEntries(ORACLE, [31898]),
...brandEntries(LINODE, [63949]),
...brandEntries(VULTR, [20473]),
...brandEntries(SCALEWAY, [12876]),
...brandEntries(IBM_CLOUD, [36351]),
...brandEntries(ALIBABA, [45102]),
...brandEntries(TENCENT, [132203]),
...brandEntries(GCORE, [199524]),
...brandEntries(CDN77, [60068]),
...brandEntries(SELECTEL, [50340, 49505]),
...brandEntries(TIMEWEB, [9123]),
...brandEntries(BEGET, [198610]),
...brandEntries(DDOS_GUARD, [57724]),
...brandEntries(META, [32934, 63293, 54115]),
...brandEntries(GOOGLE, [15169, 396982]),
...brandEntries(GITHUB, [36459]),
...brandEntries(GITLAB, [54876]),
...brandEntries(X, [13414]),
...brandEntries(LINKEDIN, [14413, 40793]),
...brandEntries(VK, [47541, 47764]),
...brandEntries(REDDIT, [394706]),
...brandEntries(DROPBOX, [19679]),
...brandEntries(SNAP, [19750]),
...brandEntries(WIKIPEDIA, [14907]),
...brandEntries(PAYPAL, [17012, 26101]),
...brandEntries(SALESFORCE, [14340]),
...brandEntries(YOUTUBE, [36040, 43515]),
...brandEntries(NETFLIX, [2906, 40027]),
...brandEntries(TWITCH, [46489]),
...brandEntries(TIKTOK, [396986, 138699]),
...brandEntries(SPOTIFY, [8403, 34081]),
...brandEntries(STEAM, [32590]),
...brandEntries(BLIZZARD, [57976]),
...brandEntries(EPIC, [395701]),
...brandEntries(RIOT, [6507, 62830]),
...brandEntries(PLAYSTATION, [33353]),
...brandEntries(ROBLOX, [22697]),
...brandEntries(UBISOFT, [197922]),
...brandEntries(DISCORD, [49544, 394141]),
...brandEntries(TELEGRAM, [62041, 59930, 211157]),
...brandEntries(ZOOM, [30103]),
...brandEntries(CHATGPT, [401115, 400645]),
...brandEntries(QUAD9, [19281]),
...brandEntries(OPENDNS, [36692]),
])
const ASN_HQ_COUNTRY = new Map<number, string>([
[13335, "US"],
[209242, "US"],
[54113, "US"],
[20940, "US"],
[16509, "US"],
[14618, "US"],
[8075, "US"],
[15169, "US"],
[32590, "US"],
[57976, "US"],
[2906, "US"],
[40027, "US"],
[36040, "US"],
[46489, "US"],
[401115, "US"],
[49544, "US"],
[32934, "US"],
[13238, "RU"],
[62041, "NL"],
[59930, "NL"],
[211157, "NL"],
...hqEntries("US", [
13335, 209242, 54113, 20940, 16625, 32787, 35994, 16702, 24319,
16509, 14618, 8987, 7224, 8075, 8068, 8069, 8070, 15169, 396982,
32590, 57976, 2906, 40027, 36040, 43515, 46489, 401115, 400645,
49544, 394141, 32934, 63293, 54115, 714, 6185, 36459, 54876, 14061,
31898, 63949, 20473, 36351, 13414, 14413, 40793, 394706, 19679, 19750,
14907, 17012, 26101, 14340, 30103, 36692, 395701, 6507, 62830, 33353, 22697,
]),
...hqEntries("IE", [9059]),
...hqEntries("SG", [138699]),
...hqEntries("DE", [24940, 213230]),
...hqEntries("FR", [16276, 12876, 197922]),
...hqEntries("CN", [45102, 132203]),
...hqEntries("LU", [199524]),
...hqEntries("CZ", [60068]),
...hqEntries("RU", [13238, 50340, 49505, 9123, 198610, 57724, 47541, 47764]),
...hqEntries("SE", [8403, 34081]),
...hqEntries("NL", [62041, 59930, 211157]),
...hqEntries("CH", [19281]),
])
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
@@ -75,8 +178,25 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
].sort((a, b) => b.prefixLen - a.prefixLen)
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
{ re: /youtube/i, hit: YOUTUBE },
{ re: /valve|\bsteam\b/i, hit: STEAM },
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
{ re: /openai/i, hit: CHATGPT },
{ re: /riot games/i, hit: RIOT },
{ re: /epic games/i, hit: EPIC },
{ re: /\bapple\b/i, hit: APPLE },
{ re: /github/i, hit: GITHUB },
{ re: /spotify/i, hit: SPOTIFY },
{ re: /twitter|\bx corp\b/i, hit: X },
{ re: /dropbox/i, hit: DROPBOX },
{ re: /akamai/i, hit: AKAMAI },
]
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
const STEAM_ASN = 32590
export function isIsoCountry(code: string): boolean {
const c = String(code ?? "").trim().toUpperCase()
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
@@ -114,10 +234,51 @@ export function brandByCidr(ip: string): BrandHit | null {
return null
}
export function brandByHolder(holder: string): BrandHit | null {
const h = String(holder ?? "").trim()
if (!h) return null
for (const row of HOLDER_BRANDS) {
if (row.re.test(h)) return row.hit
}
return null
}
/** Игровые порты Steam — только вместе с AS32590, никогда :80/:443. */
export function isSteamGamePort(proto: number, dstPort: number, srcPort: number): boolean {
if (proto !== 6 && proto !== 17) return false
const port = dstPort || srcPort
if (!port || port === 80 || port === 443) return false
if (port === 4380 || port === 3478) return true
return port >= 27000 && port <= 27100
}
export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
/**
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
* Порты Steam — только AS32590 и не выше Cloudflare CIDR.
*/
export function resolveFlowBrand(
ip: string,
asn: number,
holder: string,
proto = 0,
dstPort = 0,
srcPort = 0,
): BrandHit | null {
const cidrBrand = brandByCidr(ip)
if (cidrBrand?.service === "Cloudflare") return cidrBrand
const holderBrand = brandByHolder(holder)
if (holderBrand) return holderBrand
const fromLookup = cidrBrand || brandByAsn(asn)
if (fromLookup) return fromLookup
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
return null
}
const SKIP_MAP_SERVICES = new Set([
OTHER_SERVICE,
"GRE",
@@ -53,6 +53,71 @@ const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
assert.equal(youtube.service, "YouTube")
assert.equal(youtube.category, "Видео / стриминг")
const valve = classifyFlowDst("203.0.113.40", 17, 27015, 50000, {
prefix: "203.0.113.0/24",
asn: 64501,
country: "US",
lat: null,
lng: null,
holder: "VALVE-CORPORATION",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(valve.service, "Steam")
assert.equal(valve.category, "Игры")
const openaiHolder = classifyFlowDst("203.0.113.41", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 64502,
country: "US",
lat: null,
lng: null,
holder: "OPENAI, US",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(openaiHolder.service, "ChatGPT")
assert.equal(openaiHolder.category, "ИИ")
const cfNotSteam = classifyFlowDst("104.18.35.51", 6, 443, 1, {
prefix: "104.18.0.0/16",
asn: 32590,
country: "US",
lat: null,
lng: null,
holder: "VALVE-CORPORATION",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(cfNotSteam.service, "Cloudflare")
assert.notEqual(cfNotSteam.service, "Steam")
const awsIeu = classifyFlowDst("203.0.113.42", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 9059,
country: "IE",
lat: null,
lng: null,
holder: "AMAZON-02",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(awsIeu.service, "AWS")
assert.equal(awsIeu.category, "CDN")
const googleCloud = classifyFlowDst("203.0.113.43", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 396982,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE-CLOUD",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(googleCloud.service, "Google")
assert.equal(googleCloud.category, "Веб")
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
assert.equal(gre.service, "GRE")
assert.equal(gre.category, "Туннель")
@@ -1,4 +1,4 @@
import { lookupBrand, OTHER_SERVICE } from "./traffic-flow-brands.js"
import { OTHER_SERVICE, resolveFlowBrand } from "./traffic-flow-brands.js"
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
@@ -47,12 +47,13 @@ export function seedFlowCatalogForTests(input: {
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
const p = purpose.toLowerCase()
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
if (/voip|discord|zoom/.test(p)) return "Голос"
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
if (/веб|web|google/.test(p)) return "Веб"
if (/quad9|opendns/.test(p)) return "DNS"
if (/веб|web|google|github|paypal|vk|linkedin/.test(p)) return "Веб"
const app = applicationName(proto, dstPort, srcPort)
if (app === "DNS" || app === "SSH" || app === "BGP") return app
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
@@ -79,10 +80,7 @@ export function classifyFlowDst(
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
const hit = matchCidr(dst)
const holder = ripe?.holder ?? ""
const youtubeHolder = /youtube/i.test(holder)
const brand = youtubeHolder
? { service: "YouTube", category: "Видео / стриминг" }
: lookupBrand(dst, ripe?.asn ?? 0)
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, holder, proto, dstPort, srcPort)
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
const category = hit
@@ -5,8 +5,8 @@ import { userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import {
isNamedInternetService,
lookupBrand,
mapServiceNodeId,
resolveFlowBrand,
} from "./traffic-flow-brands.js"
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
@@ -174,10 +174,7 @@ function classifyMapDstLite(
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
if (/youtube/i.test(ripe?.holder ?? "")) {
return { service: "YouTube", category: "Видео / стриминг" }
}
const brand = lookupBrand(dst, ripe?.asn ?? 0)
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
@@ -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&apos;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>
)
}
+3 -20
View File
@@ -2,8 +2,6 @@
import { useMemo, useState } from "react"
import { Flag } from "@/components/flag"
import { OpsPanel } from "@/components/ops-panel"
import { StatusBadge } from "@/components/status-badge"
import { cn } from "@/lib/utils"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
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 [pan, setPan] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
@@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
}
return (
<OpsPanel
title="Internet path map"
description="Основной и текущий путь трафика HomeRouter → Internet"
headerRight={
<StatusBadge
status={
model?.pathState === "healthy"
? "online"
: model?.pathState === "failover"
? "degraded"
: "offline"
}
/>
}
contentClassName="px-5 pb-4"
>
<div className="flex flex-col gap-3">
{!model && (
<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>
)}
</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>
)
}
+93
View File
@@ -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>
)
}
+4
View File
@@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
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"
+161
View File
@@ -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>
)
}
+55 -46
View File
@@ -70,63 +70,72 @@ export function TrafficRxTxChart({
rx,
tx,
range = "1h",
embedded = false,
}: {
rx: number[]
tx: number[]
range?: string
/** Skip outer Frame when already inside OpsPanel / Frame. */
embedded?: boolean
}) {
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
const data = toChartData(rx, tx, rangeMinutes)
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 (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6">
<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>
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
</Frame>
)
}
+687
View File
@@ -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,
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
alert_engine: