Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39e3454aa | ||
|
|
a8f2055c77 | ||
|
|
cdeb97d841 |
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import {
|
import {
|
||||||
brandByAsn,
|
brandByAsn,
|
||||||
|
brandByHolder,
|
||||||
countryFromHolder,
|
countryFromHolder,
|
||||||
|
isSteamGamePort,
|
||||||
lookupBrand,
|
lookupBrand,
|
||||||
OTHER_SERVICE,
|
OTHER_SERVICE,
|
||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
mapServiceNodeId,
|
mapServiceNodeId,
|
||||||
|
resolveFlowBrand,
|
||||||
resolveRipeCountry,
|
resolveRipeCountry,
|
||||||
} from "./traffic-flow-brands.js"
|
} from "./traffic-flow-brands.js"
|
||||||
|
|
||||||
@@ -39,4 +42,35 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
|||||||
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||||
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
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")
|
console.log("traffic-flow-brands.test.ts: ok")
|
||||||
|
|||||||
@@ -7,59 +7,162 @@ export interface BrandHit {
|
|||||||
category: string
|
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>([
|
const ASN_BRANDS = new Map<number, BrandHit>([
|
||||||
[13335, { service: "Cloudflare", category: "CDN" }],
|
...brandEntries(CLOUDFLARE, [13335, 209242]),
|
||||||
[209242, { service: "Cloudflare", category: "CDN" }],
|
...brandEntries(FASTLY, [54113]),
|
||||||
[54113, { service: "Fastly", category: "CDN" }],
|
...brandEntries(AKAMAI, [20940, 16625, 32787, 35994, 16702, 24319]),
|
||||||
[20940, { service: "Akamai", category: "CDN" }],
|
...brandEntries(AWS, [16509, 14618, 8987, 7224, 9059]),
|
||||||
[16509, { service: "AWS", category: "CDN" }],
|
...brandEntries(MICROSOFT, [8075, 8068, 8069, 8070]),
|
||||||
[14618, { service: "AWS", category: "CDN" }],
|
...brandEntries(YANDEX, [13238]),
|
||||||
[8075, { service: "Microsoft", category: "CDN" }],
|
...brandEntries(APPLE, [714, 6185]),
|
||||||
[13238, { service: "Yandex", category: "CDN" }],
|
...brandEntries(DIGITALOCEAN, [14061]),
|
||||||
[32590, { service: "Steam", category: "Игры" }],
|
...brandEntries(HETZNER, [24940, 213230]),
|
||||||
[57976, { service: "Blizzard", category: "Игры" }],
|
...brandEntries(OVH, [16276]),
|
||||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
...brandEntries(ORACLE, [31898]),
|
||||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
...brandEntries(LINODE, [63949]),
|
||||||
[15169, { service: "Google", category: "Веб" }],
|
...brandEntries(VULTR, [20473]),
|
||||||
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
...brandEntries(SCALEWAY, [12876]),
|
||||||
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
...brandEntries(IBM_CLOUD, [36351]),
|
||||||
[401115, { service: "ChatGPT", category: "ИИ" }],
|
...brandEntries(ALIBABA, [45102]),
|
||||||
[49544, { service: "Discord", category: "Голос" }],
|
...brandEntries(TENCENT, [132203]),
|
||||||
[62041, { service: "Telegram", category: "Голос" }],
|
...brandEntries(GCORE, [199524]),
|
||||||
[59930, { service: "Telegram", category: "Голос" }],
|
...brandEntries(CDN77, [60068]),
|
||||||
[211157, { service: "Telegram", category: "Голос" }],
|
...brandEntries(SELECTEL, [50340, 49505]),
|
||||||
[32934, { service: "Meta", category: "CDN" }],
|
...brandEntries(TIMEWEB, [9123]),
|
||||||
[396986, { service: "TikTok", category: "Видео / стриминг" }],
|
...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>([
|
const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||||
[13335, "US"],
|
...hqEntries("US", [
|
||||||
[209242, "US"],
|
13335, 209242, 54113, 20940, 16625, 32787, 35994, 16702, 24319,
|
||||||
[54113, "US"],
|
16509, 14618, 8987, 7224, 8075, 8068, 8069, 8070, 15169, 396982,
|
||||||
[20940, "US"],
|
32590, 57976, 2906, 40027, 36040, 43515, 46489, 401115, 400645,
|
||||||
[16509, "US"],
|
49544, 394141, 32934, 63293, 54115, 714, 6185, 36459, 54876, 14061,
|
||||||
[14618, "US"],
|
31898, 63949, 20473, 36351, 13414, 14413, 40793, 394706, 19679, 19750,
|
||||||
[8075, "US"],
|
14907, 17012, 26101, 14340, 30103, 36692, 395701, 6507, 62830, 33353, 22697,
|
||||||
[15169, "US"],
|
]),
|
||||||
[32590, "US"],
|
...hqEntries("IE", [9059]),
|
||||||
[57976, "US"],
|
...hqEntries("SG", [138699]),
|
||||||
[2906, "US"],
|
...hqEntries("DE", [24940, 213230]),
|
||||||
[40027, "US"],
|
...hqEntries("FR", [16276, 12876, 197922]),
|
||||||
[36040, "US"],
|
...hqEntries("CN", [45102, 132203]),
|
||||||
[46489, "US"],
|
...hqEntries("LU", [199524]),
|
||||||
[401115, "US"],
|
...hqEntries("CZ", [60068]),
|
||||||
[49544, "US"],
|
...hqEntries("RU", [13238, 50340, 49505, 9123, 198610, 57724, 47541, 47764]),
|
||||||
[32934, "US"],
|
...hqEntries("SE", [8403, 34081]),
|
||||||
[13238, "RU"],
|
...hqEntries("NL", [62041, 59930, 211157]),
|
||||||
[62041, "NL"],
|
...hqEntries("CH", [19281]),
|
||||||
[59930, "NL"],
|
|
||||||
[211157, "NL"],
|
|
||||||
])
|
])
|
||||||
|
|
||||||
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 }> = [
|
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||||
{ cidr: "104.24.0.0/14", prefixLen: 14, 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 },
|
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
].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 NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||||
|
|
||||||
|
const STEAM_ASN = 32590
|
||||||
|
|
||||||
export function isIsoCountry(code: string): boolean {
|
export function isIsoCountry(code: string): boolean {
|
||||||
const c = String(code ?? "").trim().toUpperCase()
|
const c = String(code ?? "").trim().toUpperCase()
|
||||||
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
||||||
@@ -114,10 +234,51 @@ export function brandByCidr(ip: string): BrandHit | null {
|
|||||||
return 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 {
|
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||||
return brandByCidr(ip) || brandByAsn(asn)
|
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([
|
const SKIP_MAP_SERVICES = new Set([
|
||||||
OTHER_SERVICE,
|
OTHER_SERVICE,
|
||||||
"GRE",
|
"GRE",
|
||||||
|
|||||||
@@ -53,6 +53,71 @@ const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
|||||||
assert.equal(youtube.service, "YouTube")
|
assert.equal(youtube.service, "YouTube")
|
||||||
assert.equal(youtube.category, "Видео / стриминг")
|
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)
|
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
|
||||||
assert.equal(gre.service, "GRE")
|
assert.equal(gre.service, "GRE")
|
||||||
assert.equal(gre.category, "Туннель")
|
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 { db } from "../db/index.js"
|
||||||
import { evobgpSettings } from "../db/schema.js"
|
import { evobgpSettings } from "../db/schema.js"
|
||||||
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.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 {
|
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||||
const p = purpose.toLowerCase()
|
const p = purpose.toLowerCase()
|
||||||
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
|
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
|
||||||
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
|
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
|
||||||
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
|
||||||
if (/voip|discord|zoom/.test(p)) return "Голос"
|
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||||
if (/openai|chatgpt|\bai\b/.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)
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
||||||
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
||||||
@@ -79,10 +80,7 @@ export function classifyFlowDst(
|
|||||||
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
||||||
const hit = matchCidr(dst)
|
const hit = matchCidr(dst)
|
||||||
const holder = ripe?.holder ?? ""
|
const holder = ripe?.holder ?? ""
|
||||||
const youtubeHolder = /youtube/i.test(holder)
|
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, holder, proto, dstPort, srcPort)
|
||||||
const brand = youtubeHolder
|
|
||||||
? { service: "YouTube", category: "Видео / стриминг" }
|
|
||||||
: lookupBrand(dst, ripe?.asn ?? 0)
|
|
||||||
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
||||||
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
||||||
const category = hit
|
const category = hit
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
ingestParsedFlowsForServerForTests,
|
ingestParsedFlowsForServerForTests,
|
||||||
resetFlowRingsForTests,
|
resetFlowRingsForTests,
|
||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
|
import {
|
||||||
|
buildFlowMapHops,
|
||||||
|
MAP_SERVICE_MIN_NODES,
|
||||||
|
MAP_SERVICE_NODE_CAP,
|
||||||
|
pickMapServices,
|
||||||
|
resetFlowMapHopsCacheForTests,
|
||||||
|
} from "./traffic-flow-map-hops.js"
|
||||||
import { withPgOrSkip } from "../test/pg.js"
|
import { withPgOrSkip } from "../test/pg.js"
|
||||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
@@ -15,6 +21,55 @@ import {
|
|||||||
seedRipeCacheForTests,
|
seedRipeCacheForTests,
|
||||||
} from "./traffic-flow-ripe.js"
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
{
|
||||||
|
const googleOnly = pickMapServices(
|
||||||
|
[{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }],
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
assert.equal(googleOnly.length, 1)
|
||||||
|
assert.equal(googleOnly[0]?.share, 1)
|
||||||
|
|
||||||
|
const twoNamed = pickMapServices(
|
||||||
|
[
|
||||||
|
{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 },
|
||||||
|
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 },
|
||||||
|
],
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
assert.equal(twoNamed.length, 2)
|
||||||
|
|
||||||
|
const tinyTail = pickMapServices(
|
||||||
|
[
|
||||||
|
{ id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 },
|
||||||
|
...Array.from({ length: 9 }, (_, i) => ({
|
||||||
|
id: `svc:t${i}`,
|
||||||
|
label: `T${i}`,
|
||||||
|
category: "Веб",
|
||||||
|
bytes: 100,
|
||||||
|
bps: 0,
|
||||||
|
share: 0.01,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES)
|
||||||
|
assert.equal(tinyTail.at(-1)?.id, "svc:t6")
|
||||||
|
|
||||||
|
const allOff = pickMapServices(
|
||||||
|
Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
id: `svc:n${i}`,
|
||||||
|
label: `N${i}`,
|
||||||
|
category: "Веб",
|
||||||
|
bytes: 25 - i,
|
||||||
|
bps: 0,
|
||||||
|
share: 0.04,
|
||||||
|
})),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert.equal(allOff.length, MAP_SERVICE_NODE_CAP)
|
||||||
|
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await withPgOrSkip())) {
|
if (!(await withPgOrSkip())) {
|
||||||
console.log("traffic-flow-map-hops.test.ts: skip")
|
console.log("traffic-flow-map-hops.test.ts: skip")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
@@ -178,6 +233,19 @@ function googleRipe() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedRipeAsn(ip: string, asn: number, holder: string) {
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: `${ip}/32`,
|
||||||
|
asn,
|
||||||
|
country: "US",
|
||||||
|
lat: 37.4,
|
||||||
|
lng: -122.1,
|
||||||
|
holder,
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function payloadFlow(dst: string, bytes: number) {
|
function payloadFlow(dst: string, bytes: number) {
|
||||||
return {
|
return {
|
||||||
src: "10.100.1.17",
|
src: "10.100.1.17",
|
||||||
@@ -241,10 +309,106 @@ try {
|
|||||||
resetFlowMapHopsCacheForTests()
|
resetFlowMapHopsCacheForTests()
|
||||||
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
assert.equal(four.totalBytes, 10_000)
|
assert.equal(four.totalBytes, 10_000)
|
||||||
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
|
const googleFour = four.services?.find((s) => s.id === "svc:google")
|
||||||
|
assert.ok(googleFour, "единственный бренд виден при 4% от окна")
|
||||||
|
assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1")
|
||||||
resetFlowMapHopsCacheForTests()
|
resetFlowMapHopsCacheForTests()
|
||||||
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
|
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 400),
|
||||||
|
payloadFlow("104.18.35.51", 400),
|
||||||
|
payloadFlow("203.0.113.50", 9200),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
const googleTwo = two.services?.find((s) => s.id === "svc:google")
|
||||||
|
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
|
||||||
|
assert.ok(googleTwo, "Google среди брендов")
|
||||||
|
assert.ok(cfTwo, "Cloudflare среди брендов")
|
||||||
|
assert.ok(googleTwo.share >= 0.05)
|
||||||
|
assert.ok(cfTwo.share >= 0.05)
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP")
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("162.254.192.71", 2000),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
|
||||||
|
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
|
||||||
|
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
|
||||||
|
{ ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" },
|
||||||
|
{ ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" },
|
||||||
|
{ ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" },
|
||||||
|
{ ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" },
|
||||||
|
{ ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" },
|
||||||
|
{ ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" },
|
||||||
|
{ ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" },
|
||||||
|
]
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 5000),
|
||||||
|
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
const ids = new Set((top.services ?? []).map((s) => s.id))
|
||||||
|
assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте")
|
||||||
|
assert.ok(ids.has("svc:google"))
|
||||||
|
for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id)
|
||||||
|
assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт")
|
||||||
|
assert.ok(!ids.has("svc:riot"))
|
||||||
} finally {
|
} finally {
|
||||||
resetFlowRingsForTests()
|
resetFlowRingsForTests()
|
||||||
resetIfaceCacheForTests()
|
resetIfaceCacheForTests()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { userInterfaceBindings } from "../db/schema.js"
|
|||||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
import {
|
import {
|
||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
lookupBrand,
|
|
||||||
mapServiceNodeId,
|
mapServiceNodeId,
|
||||||
|
resolveFlowBrand,
|
||||||
} from "./traffic-flow-brands.js"
|
} from "./traffic-flow-brands.js"
|
||||||
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||||
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||||
@@ -20,6 +20,8 @@ import { flowDataEpoch } from "./traffic-flow-engine.js"
|
|||||||
|
|
||||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||||
export const MAP_SERVICE_NODE_CAP = 20
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
|
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
|
||||||
|
export const MAP_SERVICE_MIN_NODES = 8
|
||||||
const HOPS_CACHE_TTL_MS = 2000
|
const HOPS_CACHE_TTL_MS = 2000
|
||||||
|
|
||||||
export interface FlowMapHopsQuery {
|
export interface FlowMapHopsQuery {
|
||||||
@@ -99,6 +101,15 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
|||||||
return Math.min(100, Math.max(0, v))
|
return Math.min(100, Math.max(0, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */
|
||||||
|
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
|
||||||
|
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
|
||||||
|
const minShare = minSharePct / 100
|
||||||
|
return ranked
|
||||||
|
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
|
||||||
|
.slice(0, MAP_SERVICE_NODE_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
epoch: flowDataEpoch(),
|
epoch: flowDataEpoch(),
|
||||||
@@ -174,10 +185,7 @@ function classifyMapDstLite(
|
|||||||
if (proto === 47 || proto === 50) return null
|
if (proto === 47 || proto === 50) return null
|
||||||
const app = applicationName(proto, dstPort, srcPort)
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||||
if (/youtube/i.test(ripe?.holder ?? "")) {
|
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
|
||||||
return { service: "YouTube", category: "Видео / стриминг" }
|
|
||||||
}
|
|
||||||
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
|
||||||
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||||
return brand
|
return brand
|
||||||
}
|
}
|
||||||
@@ -439,21 +447,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const minShare = minSharePct / 100
|
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
|
||||||
let services: FlowMapService[] = [...svcTotals.entries()]
|
const services = pickMapServices(
|
||||||
.map(([id, s]) => ({
|
[...svcTotals.entries()]
|
||||||
id,
|
.map(([id, s]) => ({
|
||||||
label: s.label,
|
id,
|
||||||
category: s.category,
|
label: s.label,
|
||||||
bytes: s.bytes,
|
category: s.category,
|
||||||
bps: (s.bytes * 8) / windowSec,
|
bytes: s.bytes,
|
||||||
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
bps: (s.bytes * 8) / windowSec,
|
||||||
}))
|
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
|
||||||
.sort((a, b) => b.bytes - a.bytes)
|
}))
|
||||||
if (minSharePct > 0) {
|
.sort((a, b) => b.bytes - a.bytes),
|
||||||
services = services.filter((s) => s.share >= minShare)
|
minSharePct,
|
||||||
}
|
)
|
||||||
services = services.slice(0, MAP_SERVICE_NODE_CAP)
|
|
||||||
const keepSvc = new Set(services.map((s) => s.id))
|
const keepSvc = new Set(services.map((s) => s.id))
|
||||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||||
.filter((e) => keepSvc.has(e.toId))
|
.filter((e) => keepSvc.has(e.toId))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -125,6 +125,117 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
|
|||||||
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
|
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
|
||||||
</BrandSvg>
|
</BrandSvg>
|
||||||
)
|
)
|
||||||
|
case "apple":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M14.7 6.2c.8-.9 1.3-2.2 1.2-3.5-1.2.1-2.6.8-3.4 1.8-.8.9-1.5 2.2-1.3 3.5 1.3 0 2.6-.8 3.5-1.8Z" fill="#111" />
|
||||||
|
<path d="M16.8 12.2c0-2.2 1.8-3.3 1.9-3.4-1.1-1.6-2.7-1.8-3.3-1.8-1.4-.1-2.7.8-3.4.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-.1 2.9-2.2c1.1-1.5 1.5-3 1.5-3.1-.1 0-2.9-1.1-2.9-4.4Z" fill="#111" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "github":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#181717" />
|
||||||
|
<path d="M12 6.4c-3.1 0-5.6 2.5-5.6 5.6 0 2.5 1.6 4.6 3.8 5.3.3.1.4-.1.4-.3v-1.1c-1.6.3-1.9-.7-1.9-.7-.3-.6-.6-.8-.6-.8-.5-.4 0-.4 0-.4.6 0 .9.6.9.6.5.9 1.4.6 1.7.5.1-.4.2-.6.4-.8-1.2-.1-2.5-.6-2.5-2.8 0-.6.2-1.1.6-1.5-.1-.1-.3-.7 0-1.4 0 0 .5-.2 1.6.6.5-.1 1-.2 1.5-.2s1 .1 1.5.2c1.1-.8 1.6-.6 1.6-.6.3.7.1 1.3 0 1.4.4.4.6.9.6 1.5 0 2.2-1.3 2.6-2.5 2.8.2.2.4.5.4 1.1v1.6c0 .2.1.4.4.3 2.2-.7 3.8-2.8 3.8-5.3 0-3.1-2.5-5.6-5.6-5.6Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "gitlab":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M12 19.2 8.4 8.4h7.2L12 19.2Z" fill="#E24329" />
|
||||||
|
<path d="M12 19.2 8.4 8.4 5.2 16.2 12 19.2Z" fill="#FC6D26" />
|
||||||
|
<path d="M12 19.2 15.6 8.4 18.8 16.2 12 19.2Z" fill="#FC6D26" />
|
||||||
|
<path d="M5.2 16.2 3 8.4h5.4L5.2 16.2Z" fill="#FCA326" />
|
||||||
|
<path d="M18.8 16.2 21 8.4h-5.4l3.2 7.8Z" fill="#FCA326" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "spotify":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#1DB954" />
|
||||||
|
<path d="M7.2 10.4c3.2-1 6.8-.8 9.6.8M7.6 13c2.6-.8 5.6-.6 8 .6M8 15.4c2-.6 4.4-.4 6.2.4" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "x":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="5" fill="#111" />
|
||||||
|
<path d="M6.2 5.6h3.2l3 4.2 3.6-4.2H18l-5.2 6.1 5.4 6.7h-3.2l-3.4-4.4-4 4.4H6.4l5.6-6.4Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "vk":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="5" fill="#0077FF" />
|
||||||
|
<path d="M4.8 7.8h2.6c.1 4.2 1.9 6.7 5.4 6.7V7.8h2.4v3.9c1.5-.2 2.9-1.7 3.4-3.9h2.4c-.6 3.3-2.6 5.4-4.4 6.2 1.8.6 4.1 2.4 5 5.2h-2.8c-.7-1.9-2.2-3.4-4-3.6v3.6h-2.4v-3.6c-3.5.1-6.1-2.4-6.6-6.8Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "zoom":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="5" fill="#2D8CFF" />
|
||||||
|
<path d="M5.2 9.2h7.2a2.2 2.2 0 0 1 2.2 2.2v5.2H7.4A2.2 2.2 0 0 1 5.2 14.4Z" fill="#fff" />
|
||||||
|
<path d="M16.2 11.2 20 9.4v7.4l-3.8-1.8Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "epic":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#111" />
|
||||||
|
<path d="M8.2 7.4h7.6v2H10.6v2h4.6v2h-4.6v3.2H8.2Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "riot":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M5 19.2 12 4.2 19 19.2h-3.2L12 10.6 8.2 19.2Z" fill="#D32936" />
|
||||||
|
<path d="M9.4 19.2h5.2l-2.6-5.2Z" fill="#EB0029" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "playstation":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#003087" />
|
||||||
|
<path d="M8.2 14.6c-1 .4-1.8.2-2-.4s.4-1.2 1.6-1.6l2-.7v1.6l-1.2.4c-.6.2-.8.4-.7.6.1.2.4.2.9 0l1-.4v1.5Zm3-6.4v8.2c-1.1.4-2.1.5-2.8.2-.9-.4-.9-1.3 0-1.7.5-.2 1.2-.3 2-.2V9.4c0-1.2.5-1.8 1.4-1.5.4.2.7.6.8 1.2Zm5.2 7.6c-1.1.4-2.2.4-3 0-.8-.4-.8-1.2 0-1.6.5-.2 1.2-.3 2-.2v-2.2l-2.4.8V11l4.2-1.5v6.3Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "roblox":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="5" fill="#111" />
|
||||||
|
<path d="M8.4 6.2 17.6 8.8 15.6 17.8 6.4 15.2Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "digitalocean":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#0080FF" />
|
||||||
|
<path d="M12.4 6.2A5.8 5.8 0 0 0 7.8 16l1.6-1.5A3.6 3.6 0 1 1 16 12h-3.6Z" fill="#fff" />
|
||||||
|
<path d="M12.4 16.2h-1.6v1.6h1.6zm-1.6-2h-1.4v1.4h1.4z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "hetzner":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="4" fill="#D50C2D" />
|
||||||
|
<path d="M7.2 6.4h2.6v4.4h4.4V6.4h2.6v11.2h-2.6v-4.4H9.8v4.4H7.2Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "ovh":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect width="24" height="24" rx="4" fill="#123F6D" />
|
||||||
|
<path d="M4.6 15.6 8.4 8.4h3.2L7.8 15.6Zm6.4 0 3.8-7.2h3.2l-3.8 7.2Zm2.2 0h3.4l1.8-3.4h-3.4Z" fill="#00A2E2" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "chatgpt":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#10A37F" />
|
||||||
|
<path d="M12.2 6.2c.9-.5 2-.5 2.9 0l2.2 1.3c.9.5 1.4 1.4 1.4 2.4v2.6c0 1-.5 1.9-1.4 2.4l-2.2 1.3c-.9.5-2 .5-2.9 0l-.4-.2c.6-.4 1-1 1.1-1.7l.5.3c.4.2.9.2 1.3 0l2.2-1.3c.4-.2.6-.6.6-1.1V10c0-.4-.2-.8-.6-1.1l-2.2-1.3c-.4-.2-.9-.2-1.3 0L10.2 9c-.4.2-.6.6-.6 1.1v.4h-2V10c0-1 .5-1.9 1.4-2.4Z" fill="#fff" />
|
||||||
|
<path d="M8.8 9.6c.6-.4 1.3-.5 2-.3v2.1c0 .4.2.8.6 1.1l2.2 1.3c.4.2.9.2 1.3 0l.5-.3c.2.7.6 1.3 1.1 1.7l-.4.2c-.9.5-2 .5-2.9 0l-2.2-1.3c-.9-.5-1.4-1.4-1.4-2.4Z" fill="#fff" opacity="0.85" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return <GenericCloud size={size} />
|
return <GenericCloud size={size} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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