feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import {normalizeAlertsConfig,type PutRulesBody} from "@mmapp/contracts/alerts"
|
||||
import {can} from "@/lib/auth"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {saveAlertRules} from "@/shared/api/alerts"
|
||||
const pending=new Set<string>()
|
||||
const key=(source:string)=>"mm:alerts-rules-pending:"+source
|
||||
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
|
||||
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* The memory guard survives section navigation. */}}
|
||||
|
||||
export function useAlertsConfigWrite(base:string,live:boolean,available:boolean,expectedRevision:string|null,onBegin:()=>boolean,onEnd:()=>void){
|
||||
const source=JSON.stringify([live,base])
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true);setUnknown(unresolved(source))}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const enabled=ready&&live&&available&&!!expectedRevision&&can("mm:alerts:write")
|
||||
async function save(payload:PutRulesBody){
|
||||
if(!enabled||lock.current||unknown||!active.current||!can("mm:alerts:write"))return null
|
||||
let normalized:ReturnType<typeof normalizeAlertsConfig>
|
||||
try{normalized=normalizeAlertsConfig(payload)}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры правил");return null}
|
||||
if(!onBegin())return null
|
||||
lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);checkpoint(source,true);setUnknown(true)
|
||||
const current=()=>active.current&&can("mm:alerts:write")
|
||||
try{
|
||||
const receipt=await saveAlertRules(base,normalized,{signal:controller.current.signal,expectedRevision:expectedRevision??undefined});if(!current())return null
|
||||
checkpoint(source,false);setUnknown(false);setOutcome("Правила и группы сохранены в БД");return receipt
|
||||
}catch(e){
|
||||
const rejected=e instanceof ApiClientError&&[400,401,403,409,412,422,428].includes(e.status)
|
||||
if(rejected&¤t())checkpoint(source,false)
|
||||
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат сохранения правил не получен")};return null
|
||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}}
|
||||
}
|
||||
function review(){if(!enabled||busy)return;checkpoint(source,false);setUnknown(false)}
|
||||
return {save,busy,error,outcome,unknown,enabled,review}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback,useEffect,useRef,useState} from "react"
|
||||
import {can} from "@/lib/auth"
|
||||
import {getAlertsBgpTargets,getAlertsServerTargets,getAlertsGreTargets} from "@/shared/api/alerts-targets"
|
||||
|
||||
type Snapshot<T>={data:T|undefined;loading:boolean;error:string|null;receivedAt:string|null}
|
||||
type Reader<T>=(base:string,options:{signal:AbortSignal})=>Promise<T>
|
||||
|
||||
// Each catalog owns its lifetime and revision. The page is keyed by source.
|
||||
function useCatalog<T>(base:string,live:boolean,permission:string,read:Reader<T>){
|
||||
const [snapshot,setSnapshot]=useState<Snapshot<T>>({data:undefined,loading:false,error:null,receivedAt:null})
|
||||
const active=useRef(false),revision=useRef(0),controller=useRef<AbortController|null>(null)
|
||||
const refresh=useCallback(async()=>{
|
||||
if(!active.current||!live)return
|
||||
controller.current?.abort();const requestRevision=++revision.current
|
||||
if(!can(permission)){setSnapshot({data:undefined,loading:false,error:"Нет доступа к каталогу",receivedAt:null});return}
|
||||
const request=new AbortController();controller.current=request
|
||||
setSnapshot(previous=>({...previous,loading:true,error:null}))
|
||||
const current=()=>active.current&&requestRevision===revision.current&&can(permission)
|
||||
try{
|
||||
const data=await read(base,{signal:request.signal})
|
||||
if(current())setSnapshot({data,loading:false,error:null,receivedAt:new Date().toISOString()})
|
||||
}catch(error){
|
||||
if(current())setSnapshot(previous=>({...previous,loading:false,error:error instanceof Error?error.message:"Не удалось получить каталог"}))
|
||||
}
|
||||
},[base,live,permission,read])
|
||||
useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;controller.current?.abort()}},[refresh])
|
||||
return {...snapshot,refresh}
|
||||
}
|
||||
|
||||
async function readGre(base:string,options:{signal:AbortSignal}){
|
||||
const result=await getAlertsGreTargets(base,options)
|
||||
if(result.failures?.length)throw Error("Неполный каталог GRE: не ответили устройства — "+result.failures.map(f=>f.serverName||f.serverId).join(", "))
|
||||
return result
|
||||
}
|
||||
|
||||
export function useAlertsTargets(base:string,live:boolean){
|
||||
const bgp=useCatalog(base,live,"mm:bgp:read",getAlertsBgpTargets)
|
||||
const servers=useCatalog(base,live,"mm:servers:read",getAlertsServerTargets)
|
||||
const gre=useCatalog(base,live,"mm:filters:read",readGre)
|
||||
const refresh=()=>{void bgp.refresh();void servers.refresh();void gre.refresh()}
|
||||
return {bgp,servers,gre,refresh}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
import {useEffect,useRef,useState,useSyncExternalStore} from "react"
|
||||
import {testTelegramSchema,type TestTelegramBody} from "@mmapp/contracts/alerts"
|
||||
import {can} from "@/lib/auth"
|
||||
import {telegramPending,subscribeTelegramPending,setTelegramPending} from "@/lib/alerts-telegram-checkpoint"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {testAlertsTelegram} from "@/shared/api/alerts"
|
||||
|
||||
export function useAlertsTelegramPreview(base:string,live:boolean,available:boolean,onBegin:()=>boolean,onEnd:()=>void){
|
||||
const source=JSON.stringify([live,base])
|
||||
const unknown=useSyncExternalStore(subscribeTelegramPending,()=>telegramPending(source),()=>false)
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const enabled=live&&available&&can("mm:alerts:write")
|
||||
async function test(payload:TestTelegramBody){
|
||||
if(!enabled||lock.current||telegramPending(source)||!active.current||!can("mm:alerts:write"))return false
|
||||
const parsed=testTelegramSchema.strict().safeParse(payload)
|
||||
if(!parsed.success){setError("Проверьте параметры тестового сообщения");return false}
|
||||
if(!onBegin())return false
|
||||
lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);setTelegramPending(source,true)
|
||||
const current=()=>active.current&&can("mm:alerts:write")
|
||||
try{
|
||||
await testAlertsTelegram(base,parsed.data,{signal:controller.current.signal});if(!current())return false
|
||||
setTelegramPending(source,false);setOutcome("Telegram API подтвердил приём тестового правила. Проверьте его в чате.");return true
|
||||
}catch(e){
|
||||
if(e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)&¤t())setTelegramPending(source,false)
|
||||
if(current())setError(e instanceof Error?e.message:"Результат отправки не получен");return false
|
||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}}
|
||||
}
|
||||
function review(){if(!enabled||busy)return;setTelegramPending(source,false)}
|
||||
function clear(){setError(null);setOutcome(null)}
|
||||
return {test,busy,error,outcome,unknown,enabled,review,clear}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
authPortalBaseUrl,
|
||||
DEFAULT_APP_SWITCHER_CONFIG,
|
||||
mergeWithLocalApp,
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
getAuthConfigSync,
|
||||
getClaims,
|
||||
} from "@/lib/auth"
|
||||
|
||||
function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
|
||||
const claims = getClaims()
|
||||
if (!claims?.apps?.length) return config
|
||||
const allowed = new Set(claims.apps)
|
||||
const apps = config.apps.filter(
|
||||
(a) => a.id === "mm" || allowed.has(a.id),
|
||||
)
|
||||
return { ...config, apps: apps.length > 0 ? apps : config.apps }
|
||||
}
|
||||
|
||||
export function useAppSwitcherConfig(): {
|
||||
config: AppSwitcherConfig
|
||||
isLoading: boolean
|
||||
} {
|
||||
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
|
||||
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
|
||||
const portal =
|
||||
getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
|
||||
if (!portal) {
|
||||
setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${portal}/api/v1/app-switcher`)
|
||||
if (!res.ok) throw new Error("switcher fetch failed")
|
||||
const raw: unknown = await res.json()
|
||||
if (cancelled) return
|
||||
const parsed = parseAppSwitcherConfig(raw)
|
||||
setConfig(
|
||||
filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
|
||||
)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setConfig(
|
||||
filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { config, isLoading }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { encodeBackupQuery, patchBackupsUrl, readBackupsUrl, validateBackupQuery, type BackupKindFilter, type BackupsTab } from "@/lib/data-filters/backups-url-state"
|
||||
|
||||
export function useBackupsUrlState() {
|
||||
const params = useSearchParams()
|
||||
const raw = params.toString()
|
||||
const state = useMemo(() => readBackupsUrl(new URLSearchParams(raw)), [raw])
|
||||
const filterKey = params.get("filters")
|
||||
const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null)
|
||||
useEffect(() => {
|
||||
const reset = () => setDraft(null)
|
||||
window.addEventListener("popstate", reset)
|
||||
return () => window.removeEventListener("popstate", reset)
|
||||
}, [])
|
||||
function write(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchBackupsUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||
if (href === window.location.pathname + window.location.search + window.location.hash) return
|
||||
if (replace) window.history.replaceState(null, "", href)
|
||||
else window.history.pushState(null, "", href)
|
||||
}
|
||||
function setFilters(query: FilterQuery) {
|
||||
if (validateBackupQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return }
|
||||
try {
|
||||
const encoded = encodeBackupQuery(query)
|
||||
setDraft(null)
|
||||
write({ filters: encoded })
|
||||
} catch (error) {
|
||||
setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." })
|
||||
}
|
||||
}
|
||||
const currentDraft = draft?.key === filterKey ? draft : null
|
||||
return {
|
||||
search: state.search,
|
||||
kindFilter: state.kind,
|
||||
tab: state.tab,
|
||||
filters: currentDraft?.query ?? state.query,
|
||||
error: state.error ?? currentDraft?.error ?? null,
|
||||
hasFilters: params.has("q") || params.has("kind") || params.has("filters") || currentDraft !== null,
|
||||
setSearch: (value: string) => write({ q: value }, true),
|
||||
setKindFilter: (value: BackupKindFilter) => write({ kind: value === "all" ? null : value }),
|
||||
setTab: (value: BackupsTab) => write({ tab: value === "history" ? null : value }),
|
||||
setFilters,
|
||||
reset: () => { setDraft(null); write({ q: null, kind: null, tab: null, filters: null }) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { encodeBgpQuery, patchBgpUrl, readBgpUrl, validateBgpQuery, type BgpTypeFilter, type BgpStateFilter } from "@/lib/data-filters/bgp-url-state"
|
||||
|
||||
export function useBgpUrlState() {
|
||||
const params = useSearchParams()
|
||||
const raw = params.toString()
|
||||
const state = useMemo(() => readBgpUrl(new URLSearchParams(raw)), [raw])
|
||||
const filterKey = params.get("filters")
|
||||
const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null)
|
||||
useEffect(() => {
|
||||
const reset = () => setDraft(null)
|
||||
window.addEventListener("popstate", reset)
|
||||
return () => window.removeEventListener("popstate", reset)
|
||||
}, [])
|
||||
function write(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchBgpUrl(new URLSearchParams(window.location.search), changes)
|
||||
if (href === window.location.pathname + window.location.search) return
|
||||
if (replace) window.history.replaceState(null, "", href)
|
||||
else window.history.pushState(null, "", href)
|
||||
}
|
||||
function setFilters(query: FilterQuery) {
|
||||
if (validateBgpQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return }
|
||||
try {
|
||||
const encoded = encodeBgpQuery(query)
|
||||
setDraft(null)
|
||||
write({ filters: encoded })
|
||||
} catch (error) {
|
||||
setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." })
|
||||
}
|
||||
}
|
||||
const currentDraft = draft?.key === filterKey ? draft : null
|
||||
return {
|
||||
search: state.search,
|
||||
typeFilter: state.type,
|
||||
stateFilter: state.state,
|
||||
filters: currentDraft?.query ?? state.query,
|
||||
error: state.error ?? currentDraft?.error ?? null,
|
||||
hasFilters: params.has("q") || params.has("type") || params.has("state") || params.has("filters") || currentDraft !== null,
|
||||
setSearch: (value: string) => write({ q: value }, true),
|
||||
setTypeFilter: (value: BgpTypeFilter) => write({ type: value === "all" ? null : value }),
|
||||
setStateFilter: (value: BgpStateFilter) => write({ state: value === "all" ? null : value }),
|
||||
setFilters,
|
||||
reset: () => { setDraft(null); write({ q: null, type: null, state: null, filters: null }) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 Server,
|
||||
} from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { listEvents } from "@/shared/api/events"
|
||||
import type { EventItem } from "@mmapp/contracts/events"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
import {
|
||||
buildDashboardInternetPath,
|
||||
resolveDefaultRouteLookup,
|
||||
type InternetPathViewModel,
|
||||
} from "@/lib/dashboard-internet-path"
|
||||
|
||||
import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
||||
|
||||
import {can,ensureAuthConfig} from "@/lib/auth"
|
||||
import {parseDashboardPath,parseDashboardFilters,parseDashboardSpeedProbes,parseDashboardWanRuntime} from "@/lib/dashboard-path-response"
|
||||
import {parseDashboardGre,parseDashboardWireGuard,parseDashboardVxlan,type DashboardGreTunnel} from "@/lib/dashboard-overlays"
|
||||
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||
import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
|
||||
import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic"
|
||||
import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp"
|
||||
import {dashboardRequestPermission} from "@/lib/dashboard-permissions"
|
||||
async function dashboardRequest<T>(path:string,work:()=>Promise<T>,method="GET"):Promise<T>{
|
||||
await ensureAuthConfig();const permission=dashboardRequestPermission(method,path)
|
||||
if(!can(permission))throw Error("Нет доступа к источнику: "+permission)
|
||||
const result=await work();if(!can(permission))throw Error("Доступ к источнику отозван: "+permission)
|
||||
return result
|
||||
}
|
||||
|
||||
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 dashboardRequest(path,()=>requestJson<T>(backendUrl,path,init),init?.method??"GET")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
type BackendServerRow=DashboardServerRow
|
||||
|
||||
type ApiGreTunnelRow=DashboardGreTunnel
|
||||
|
||||
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|null)[]>; labels?: Record<string, string>; subtitle: string }
|
||||
| { kind: "live"; series: Record<string, (number|null)[]>; 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 ?? "unknown",
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions,
|
||||
wanUplinks,
|
||||
}
|
||||
}
|
||||
|
||||
function mockOverlayItems(): OverlayItem[] {
|
||||
const gre: OverlayItem[] = mockGreTunnels.map((t) => ({
|
||||
id: JSON.stringify(["gre", t.serverId, t.id]),
|
||||
name: t.name,
|
||||
kind: "gre",
|
||||
href: "/gre?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id),
|
||||
status: t.status,
|
||||
}))
|
||||
const wg: OverlayItem[] = mockServers.flatMap((s) =>
|
||||
(s.wireGuardIfaces ?? []).map((iface) => ({
|
||||
id: JSON.stringify(["wg", s.id, iface.id]),
|
||||
name: iface.name,
|
||||
kind: "wg" as const,
|
||||
href: "/wireguard?server=" + encodeURIComponent(s.id) + "&interface=" + encodeURIComponent(iface.id),
|
||||
status: iface.status,
|
||||
})),
|
||||
)
|
||||
const vx: OverlayItem[] = mockVxlan.map((t) => ({
|
||||
id: JSON.stringify(["vxlan", t.serverId, t.id]),
|
||||
name: t.name,
|
||||
kind: "vxlan",
|
||||
href: "/vxlan?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id),
|
||||
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<DashboardProbe[] | null>(null)
|
||||
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
||||
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
|
||||
const [bgp, setBgp] = useState<DashboardBgp | null>(null)
|
||||
const [trafficSeries, setTrafficSeries] = useState<DashboardTraffic | 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 [sources, setSources] = useState<DashboardSources>({})
|
||||
const snapshotRequest = useRef(0)
|
||||
const eventsRequest = useRef(0)
|
||||
const recordSource = useCallback((source: DashboardSource, error: string | null, sampledAt?: string) => {
|
||||
setSources(previous => updateDashboardSource(previous, source, error, Date.now(), sampledAt))
|
||||
}, [])
|
||||
|
||||
const fetchSnapshot = useCallback(async (_silent: boolean) => {
|
||||
if (!isLive) return
|
||||
const request = ++snapshotRequest.current
|
||||
const current = () => request === snapshotRequest.current
|
||||
setLoading(true)
|
||||
setInternetPathLoading(true)
|
||||
try {
|
||||
await ensureAuthConfig();if(!current())return
|
||||
if(!can("mm:servers:read"))setLiveServers(null)
|
||||
if(!can("mm:uptime:read"))setLiveProbes(null)
|
||||
if(!can("mm:bgp:read"))setBgp(null)
|
||||
if(!can("mm:traffic:read"))setTrafficSeries(null)
|
||||
if(!can("mm:network:read"))setInternetPath(null)
|
||||
setOverlayItems(previous=>previous?.filter(item=>can(item.kind==="gre"?"mm:filters:read":item.kind==="wg"?"mm:network:read":"mm:dashboard:read"))??null)
|
||||
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
|
||||
apiFetch<unknown>("/api/uptime/overview?range=1h").then(parseDashboardProbes),
|
||||
apiFetch<unknown>("/api/servers").then(parseDashboardServers),
|
||||
apiFetch<unknown>("/api/filters/rules").then(parseDashboardFilters),
|
||||
apiFetch<unknown>("/api/bgp/catalog").then(parseDashboardBgp),
|
||||
apiFetch<unknown>("/api/internet-path/latest").then(parseDashboardPath),
|
||||
apiFetch<unknown>("/api/filters/gre-tunnels").then(parseDashboardGre),
|
||||
apiFetch<unknown>("/api/wireguard").then(parseDashboardWireGuard),
|
||||
apiFetch<unknown>("/api/traffic/servers?range=1h").then(parseDashboardTraffic),
|
||||
apiFetch<unknown>("/api/vxlan").then(parseDashboardVxlan),
|
||||
])
|
||||
|
||||
if (!current()) return
|
||||
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
|
||||
for (const [key, result] of Object.entries(results)) {
|
||||
recordSource(key as DashboardSource, result.status === "rejected"
|
||||
? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null,key==="traffic"&&trafficRes.status==="fulfilled"?trafficRes.value.sampledAt??undefined:key==="probes"&&overviewRes.status==="fulfilled"?overviewRes.value.sampledAt??undefined:undefined)
|
||||
}
|
||||
const failures = Object.values(results).filter(result => result.status === "rejected").length
|
||||
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
|
||||
|
||||
if (overviewRes.status === "fulfilled") {
|
||||
setLiveProbes(overviewRes.value.probes)
|
||||
}
|
||||
|
||||
let serversMapped: Server[] = []
|
||||
if (serversRes.status === "fulfilled") {
|
||||
serversMapped = serversRes.value.map(mapBackendToServer)
|
||||
setLiveServers(serversMapped)
|
||||
}
|
||||
|
||||
if (br.status === "fulfilled") setBgp(br.value)
|
||||
|
||||
const greItems: OverlayItem[] =
|
||||
greRes.status === "fulfilled"
|
||||
? (greRes.value.tunnels ?? []).map((t) => ({
|
||||
id: JSON.stringify(["gre", t.serverId, t.id]),
|
||||
name: t.name,
|
||||
kind: "gre" as const,
|
||||
href: "/gre?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id),
|
||||
status: t.status,
|
||||
}))
|
||||
: []
|
||||
const wgItems: OverlayItem[] =
|
||||
wgRes.status === "fulfilled"
|
||||
? wgRes.value.interfaces.map((iface) => ({
|
||||
id: JSON.stringify(["wg", iface.serverId, iface.id]),
|
||||
name: iface.name,
|
||||
kind: "wg" as const,
|
||||
href: "/wireguard?server=" + encodeURIComponent(iface.serverId) + "&interface=" + encodeURIComponent(iface.id),
|
||||
status: iface.status,
|
||||
}))
|
||||
: []
|
||||
const vxItems: OverlayItem[] =
|
||||
vxRes.status === "fulfilled"
|
||||
? (vxRes.value.tunnels ?? []).map((t) => ({
|
||||
id: JSON.stringify(["vxlan", t.serverId, t.id]),
|
||||
name: t.name,
|
||||
kind: "vxlan" as const,
|
||||
href: "/vxlan?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id),
|
||||
status: t.status,
|
||||
}))
|
||||
: []
|
||||
setOverlayItems(previous => [
|
||||
...(greRes.status === "fulfilled" ? greItems : previous?.filter(item => item.kind === "gre") ?? []),
|
||||
...(wgRes.status === "fulfilled" ? wgItems : previous?.filter(item => item.kind === "wg") ?? []),
|
||||
...(vxRes.status === "fulfilled" ? vxItems : previous?.filter(item => item.kind === "vxlan") ?? []),
|
||||
])
|
||||
|
||||
if (trafficRes.status === "fulfilled") setTrafficSeries(trafficRes.value)
|
||||
|
||||
const greMapped =
|
||||
greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : []
|
||||
|
||||
if (ipRes.status === "rejected") throw ipRes.reason
|
||||
const snap = ipRes.value.snapshot
|
||||
if (snap || serversMapped.length > 0) {
|
||||
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 {
|
||||
if (serversRes.status === "rejected" || fr.status === "rejected" || greRes.status === "rejected") {
|
||||
throw new Error("Не удалось получить исходные данные Internet path")
|
||||
}
|
||||
let lookupFailed = false
|
||||
const checkedFetch: typeof apiFetch = async (path, init) => {
|
||||
if (!current()) throw new Error("Обновление заменено новым запросом")
|
||||
try { return await apiFetch(path, init) }
|
||||
catch (error) { lookupFailed = true; throw error }
|
||||
}
|
||||
const filterRulesets = fr.status === "fulfilled" ? fr.value.rulesets : []
|
||||
const homes = serversMapped.filter((s) => s.type === "home-router" && s.enabled)
|
||||
const lookups = await Promise.all(
|
||||
homes.map(async (h) => ({
|
||||
id: h.id,
|
||||
lookup: await resolveDefaultRouteLookup(checkedFetch, h.id),
|
||||
})),
|
||||
)
|
||||
if (!current()) return
|
||||
const wanRuntimeRows = await Promise.all(
|
||||
homes.map(async (h) => {
|
||||
try {
|
||||
const rt = await checkedFetch<unknown>(`/api/servers/${h.id}/wan-runtime`).then(parseDashboardWanRuntime)
|
||||
return { id: h.id, runtime: rt }
|
||||
} catch {
|
||||
lookupFailed=true
|
||||
return { id: h.id, runtime: null }
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!current()) return
|
||||
const speedRes = await checkedFetch<unknown>("/api/uptime/speed-probes").then(parseDashboardSpeedProbes).catch(error=>{lookupFailed=true;throw Error("Не удалось обновить все данные Internet path: "+(error instanceof Error?error.message:String(error)))})
|
||||
if (!current()) return
|
||||
if (lookupFailed) throw new Error("Не удалось обновить все данные Internet path")
|
||||
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 {
|
||||
if (serversRes.status === "rejected") throw serversRes.reason
|
||||
setInternetPath(null)
|
||||
}
|
||||
recordSource("internetPath", null, snap?.sampledAt)
|
||||
setInternetPathError(null)
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд"
|
||||
setInternetPathError(msg)
|
||||
recordSource("internetPath", msg)
|
||||
} finally {
|
||||
if (current()) {
|
||||
setLoading(false)
|
||||
setInternetPathLoading(false)
|
||||
}
|
||||
}
|
||||
}, [apiFetch, isLive, recordSource])
|
||||
|
||||
const fetchRecentEvents = useCallback(async (_silent: boolean) => {
|
||||
if (!isLive) {
|
||||
setRecentEvents([])
|
||||
setEventsError(null)
|
||||
return
|
||||
}
|
||||
const request = ++eventsRequest.current
|
||||
const current = () => request === eventsRequest.current
|
||||
setEventsLoading(true)
|
||||
try {
|
||||
const rows = await dashboardRequest("/api/events",()=>listEvents(backendUrl, { limit: 6 }))
|
||||
if (!current()) return
|
||||
setRecentEvents(rows)
|
||||
recordSource("events", null)
|
||||
setEventsError(null)
|
||||
} catch (err) {
|
||||
if (!current()) return
|
||||
const message = err instanceof Error ? err.message : "Не удалось загрузить события"
|
||||
recordSource("events", message)
|
||||
setEventsError(message)
|
||||
} finally {
|
||||
if (current()) setEventsLoading(false)
|
||||
}
|
||||
}, [backendUrl, isLive, recordSource])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLiveProbes(null)
|
||||
setLiveServers(null)
|
||||
setOverlayItems(null)
|
||||
setBgp(null)
|
||||
setTrafficSeries(null)
|
||||
setError(null)
|
||||
setInternetPath(null)
|
||||
setInternetPathError(null)
|
||||
setRecentEvents([])
|
||||
setEventsError(null)
|
||||
setSources({})
|
||||
setLoading(false)
|
||||
setEventsLoading(false)
|
||||
setInternetPathLoading(false)
|
||||
void fetchSnapshot(false)
|
||||
void fetchRecentEvents(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
snapshotRequest.current += 1
|
||||
eventsRequest.current += 1
|
||||
}
|
||||
}, [fetchSnapshot, 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(() => {
|
||||
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,observationsKnown:true,sampledAt:null,
|
||||
}
|
||||
}
|
||||
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")
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
hint: s.status === "unknown" ? "Статус неизвестен" : s.status === "degraded" ? "Ухудшение" : "Не в сети",
|
||||
href: "/servers?server=" + encodeURIComponent(s.id),
|
||||
tone: s.status === "degraded" || s.status === "unknown" ? ("warning" as const) : ("destructive" as const),
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const attentionOverlay: AttentionRow[] = useMemo(() => {
|
||||
return overlay.items
|
||||
.filter((i) => i.status !== "up")
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
title: i.name,
|
||||
hint: i.status === "degraded" ? "Ухудшение" : "Не работает",
|
||||
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" || p.status === "unknown")
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.name,
|
||||
hint: p.status === "unknown" ? "Статус неизвестен" : p.status === "warn" ? "Предупреждение" : "Ошибка",
|
||||
href: "/uptime?probe=" + encodeURIComponent(p.id),
|
||||
tone: p.status === "warn" || p.status === "unknown" ? ("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 && loading && liveServers === null && liveProbes === null
|
||||
const kpiLoading = !prefsHydrated || dataPending
|
||||
|
||||
const probesSubtitle = !prefsHydrated
|
||||
? "Загрузка…"
|
||||
: !isLive
|
||||
? mockActiveProbes.length > 0
|
||||
? `${mockActiveProbes.length} на дашборде · демо`
|
||||
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||
: sources.probes?.error && liveProbes === null
|
||||
? "Данные проб недоступны"
|
||||
: activeProbes.length > 0
|
||||
? `${activeProbes.length} на дашборде · 1 ч`
|
||||
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||
|
||||
return {
|
||||
prefsHydrated,
|
||||
isLive,
|
||||
loading: kpiLoading,
|
||||
error,
|
||||
sources,
|
||||
refreshing: isLive && (loading || eventsLoading),
|
||||
availability: {
|
||||
servers: !isLive || liveServers !== null,
|
||||
probes: !isLive || liveProbes !== null,
|
||||
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null),
|
||||
},
|
||||
reliable: {
|
||||
servers: !isLive || (liveServers !== null && !sources.servers?.error && liveServers.every(s=>s.status!=="unknown")),
|
||||
probes: !isLive || (liveProbes !== null && !sources.probes?.error && liveProbes.filter(p=>p.enabled).every(p=>p.status!=="unknown")),
|
||||
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error),
|
||||
},
|
||||
retry: () => {
|
||||
void fetchSnapshot(false)
|
||||
void fetchRecentEvents(false)
|
||||
},
|
||||
servers,
|
||||
activeProbes,
|
||||
overlay,
|
||||
bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 },
|
||||
traffic,
|
||||
onlineCount,
|
||||
offlineCount:servers.filter(s=>s.status==="offline").length,
|
||||
degradedServerCount:servers.filter(s=>s.status==="degraded").length,
|
||||
unknownServerCount:servers.filter(s=>s.status==="unknown").length,
|
||||
totalServers: servers.length,
|
||||
probeDown,
|
||||
probeWarn,
|
||||
probeCount: enabledProbes.length,
|
||||
unknownProbeCount:enabledProbes.filter(p=>p.status==="unknown").length,
|
||||
latency,
|
||||
recentEvents,
|
||||
eventsLoading,
|
||||
eventsError,
|
||||
internetPath,
|
||||
internetPathLoading: isLive && internetPathLoading && !internetPath,
|
||||
internetPathError,
|
||||
attentionServers,
|
||||
attentionOverlay,
|
||||
attentionProbes,
|
||||
probesSubtitle,
|
||||
probesLoading: isLive && loading && liveProbes === null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
type InputHTMLAttributes,
|
||||
} from "react"
|
||||
|
||||
export type FileMetadata = {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export type FileWithPreview = {
|
||||
file: File | FileMetadata
|
||||
id: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type FileUploadOptions = {
|
||||
maxFiles?: number // Only used when multiple is true, defaults to Infinity
|
||||
maxSize?: number // in bytes
|
||||
accept?: string
|
||||
multiple?: boolean // Defaults to false
|
||||
initialFiles?: FileMetadata[]
|
||||
onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
|
||||
onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
|
||||
onError?: (errors: string[]) => void
|
||||
}
|
||||
|
||||
export type FileUploadState = {
|
||||
files: FileWithPreview[]
|
||||
isDragging: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type FileUploadActions = {
|
||||
addFiles: (files: FileList | File[]) => void
|
||||
removeFile: (id: string) => void
|
||||
clearFiles: () => void
|
||||
clearErrors: () => void
|
||||
handleDragEnter: (e: DragEvent<HTMLElement>) => void
|
||||
handleDragLeave: (e: DragEvent<HTMLElement>) => void
|
||||
handleDragOver: (e: DragEvent<HTMLElement>) => void
|
||||
handleDrop: (e: DragEvent<HTMLElement>) => void
|
||||
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
openFileDialog: () => void
|
||||
getInputProps: (
|
||||
props?: InputHTMLAttributes<HTMLInputElement>
|
||||
) => InputHTMLAttributes<HTMLInputElement> & {
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
}
|
||||
}
|
||||
|
||||
export const useFileUpload = (
|
||||
options: FileUploadOptions = {}
|
||||
): [FileUploadState, FileUploadActions] => {
|
||||
const {
|
||||
maxFiles = Number.POSITIVE_INFINITY,
|
||||
maxSize = Number.POSITIVE_INFINITY,
|
||||
accept = "*",
|
||||
multiple = false,
|
||||
initialFiles = [],
|
||||
onFilesChange,
|
||||
onFilesAdded,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
const [state, setState] = useState<FileUploadState>({
|
||||
files: initialFiles.map((file) => ({
|
||||
file,
|
||||
id: file.id,
|
||||
preview: file.url,
|
||||
})),
|
||||
isDragging: false,
|
||||
errors: [],
|
||||
})
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const validateFile = useCallback(
|
||||
(file: File | FileMetadata): string | null => {
|
||||
if (file instanceof File) {
|
||||
if (file.size > maxSize) {
|
||||
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||
}
|
||||
} else {
|
||||
if (file.size > maxSize) {
|
||||
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||
}
|
||||
}
|
||||
|
||||
if (accept !== "*") {
|
||||
const acceptedTypes = accept.split(",").map((type) => type.trim())
|
||||
const fileType = file instanceof File ? file.type || "" : file.type
|
||||
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`
|
||||
|
||||
const isAccepted = acceptedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return fileExtension.toLowerCase() === type.toLowerCase()
|
||||
}
|
||||
if (type.endsWith("/*")) {
|
||||
const baseType = type.split("/")[0]
|
||||
return fileType.startsWith(`${baseType}/`)
|
||||
}
|
||||
return fileType === type
|
||||
})
|
||||
|
||||
if (!isAccepted) {
|
||||
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[accept, maxSize]
|
||||
)
|
||||
|
||||
const createPreview = useCallback(
|
||||
(file: File | FileMetadata): string | undefined => {
|
||||
if (file instanceof File) {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
return file.url
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const generateUniqueId = useCallback((file: File | FileMetadata): string => {
|
||||
if (file instanceof File) {
|
||||
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
return file.id
|
||||
}, [])
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setState((prev) => {
|
||||
// Clean up object URLs
|
||||
for (const file of prev.files) {
|
||||
if (
|
||||
file.preview &&
|
||||
file.file instanceof File &&
|
||||
file.file.type.startsWith("image/")
|
||||
) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...prev,
|
||||
files: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
onFilesChange?.(newState.files)
|
||||
return newState
|
||||
})
|
||||
}, [onFilesChange])
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: FileList | File[]) => {
|
||||
if (!newFiles || newFiles.length === 0) return
|
||||
|
||||
const newFilesArray = Array.from(newFiles)
|
||||
const errors: string[] = []
|
||||
|
||||
// Clear existing errors when new files are uploaded
|
||||
setState((prev) => ({ ...prev, errors: [] }))
|
||||
|
||||
// In single file mode, clear existing files first
|
||||
if (!multiple) {
|
||||
clearFiles()
|
||||
}
|
||||
|
||||
// Check if adding these files would exceed maxFiles (only in multiple mode)
|
||||
if (
|
||||
multiple &&
|
||||
maxFiles !== Number.POSITIVE_INFINITY &&
|
||||
state.files.length + newFilesArray.length > maxFiles
|
||||
) {
|
||||
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
|
||||
onError?.(errors)
|
||||
setState((prev) => ({ ...prev, errors }))
|
||||
return
|
||||
}
|
||||
|
||||
const validFiles: FileWithPreview[] = []
|
||||
|
||||
for (const file of newFilesArray) {
|
||||
// Only check for duplicates if multiple files are allowed
|
||||
if (multiple) {
|
||||
const isDuplicate = state.files.some(
|
||||
(existingFile) =>
|
||||
existingFile.file.name === file.name &&
|
||||
existingFile.file.size === file.size
|
||||
)
|
||||
|
||||
// Skip duplicate files silently
|
||||
if (isDuplicate) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxSize) {
|
||||
errors.push(
|
||||
multiple
|
||||
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
|
||||
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
errors.push(error)
|
||||
} else {
|
||||
validFiles.push({
|
||||
file,
|
||||
id: generateUniqueId(file),
|
||||
preview: createPreview(file),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only update state if we have valid files to add
|
||||
if (validFiles.length > 0) {
|
||||
// Call the onFilesAdded callback with the newly added valid files
|
||||
onFilesAdded?.(validFiles)
|
||||
|
||||
setState((prev) => {
|
||||
const newFiles = !multiple
|
||||
? validFiles
|
||||
: [...prev.files, ...validFiles]
|
||||
onFilesChange?.(newFiles)
|
||||
return {
|
||||
...prev,
|
||||
files: newFiles,
|
||||
errors,
|
||||
}
|
||||
})
|
||||
} else if (errors.length > 0) {
|
||||
onError?.(errors)
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
|
||||
// Reset input value after handling files
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
},
|
||||
[
|
||||
state.files,
|
||||
maxFiles,
|
||||
multiple,
|
||||
maxSize,
|
||||
validateFile,
|
||||
createPreview,
|
||||
generateUniqueId,
|
||||
clearFiles,
|
||||
onFilesChange,
|
||||
onFilesAdded,
|
||||
]
|
||||
)
|
||||
|
||||
const removeFile = useCallback(
|
||||
(id: string) => {
|
||||
setState((prev) => {
|
||||
const fileToRemove = prev.files.find((file) => file.id === id)
|
||||
if (
|
||||
fileToRemove &&
|
||||
fileToRemove.preview &&
|
||||
fileToRemove.file instanceof File &&
|
||||
fileToRemove.file.type.startsWith("image/")
|
||||
) {
|
||||
URL.revokeObjectURL(fileToRemove.preview)
|
||||
}
|
||||
|
||||
const newFiles = prev.files.filter((file) => file.id !== id)
|
||||
onFilesChange?.(newFiles)
|
||||
|
||||
return {
|
||||
...prev,
|
||||
files: newFiles,
|
||||
errors: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
[onFilesChange]
|
||||
)
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: [],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState((prev) => ({ ...prev, isDragging: true }))
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isDragging: false }))
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState((prev) => ({ ...prev, isDragging: false }))
|
||||
|
||||
// Don't process files if the input is disabled
|
||||
if (inputRef.current?.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
// In single file mode, only use the first file
|
||||
if (!multiple) {
|
||||
const file = e.dataTransfer.files[0]
|
||||
addFiles([file])
|
||||
} else {
|
||||
addFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
},
|
||||
[addFiles, multiple]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
addFiles(e.target.files)
|
||||
}
|
||||
},
|
||||
[addFiles]
|
||||
)
|
||||
|
||||
const openFileDialog = useCallback(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.click()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getInputProps = useCallback(
|
||||
(props: InputHTMLAttributes<HTMLInputElement> = {}) => {
|
||||
return {
|
||||
...props,
|
||||
type: "file" as const,
|
||||
onChange: handleFileChange,
|
||||
accept: props.accept || accept,
|
||||
multiple: props.multiple !== undefined ? props.multiple : multiple,
|
||||
ref: inputRef,
|
||||
}
|
||||
},
|
||||
[accept, multiple, handleFileChange]
|
||||
)
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
addFiles,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
clearErrors,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
openFileDialog,
|
||||
getInputProps,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Helper function to format bytes to human-readable format
|
||||
export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { flowAnalyticsDtoSchema, type FlowAnalyticsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
import { flowQuery } from "@/shared/api/traffic-flow"
|
||||
|
||||
function parseSseBlock(block: string): { event: string; data: string } {
|
||||
let event = "message"
|
||||
const dataLines: string[] = []
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim()
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
return { event, data: dataLines.join("\n") }
|
||||
}
|
||||
|
||||
export function isValidFlowLiveSample(value: unknown): value is FlowAnalyticsDto {
|
||||
const parsed = flowAnalyticsDtoSchema.safeParse(value)
|
||||
return parsed.success && [...parsed.data.rxSeries, ...parsed.data.txSeries].every(value => Number.isFinite(value) && value >= 0)
|
||||
}
|
||||
|
||||
export function useFlowLive(opts: {
|
||||
enabled: boolean
|
||||
backendUrl: string
|
||||
range: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
dedup?: boolean
|
||||
excludeMesh?: boolean
|
||||
excludeOverlay?: boolean
|
||||
}): { sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | null } {
|
||||
const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay])
|
||||
const [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | null } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled) return
|
||||
const ac = new AbortController()
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const stopTimer = () => clearTimeout(sampleTimer)
|
||||
const setError = (error: string) => {
|
||||
stopTimer()
|
||||
if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error }))
|
||||
}
|
||||
const path = `/api/traffic/flow/live${flowQuery({
|
||||
range: opts.range,
|
||||
serverId: opts.serverId,
|
||||
userId: opts.userId,
|
||||
iface: opts.iface,
|
||||
dedup: opts.dedup,
|
||||
excludeMesh: opts.excludeMesh,
|
||||
excludeOverlay: opts.excludeOverlay,
|
||||
})}`
|
||||
const url = resolveApiUrl(opts.backendUrl, path)
|
||||
// Backend ticks every 1.5–2s; tolerate several missed samples. Heartbeats do not reset this timer.
|
||||
const awaitSample = () => {
|
||||
stopTimer()
|
||||
sampleTimer = setTimeout(() => setError("Нет обновлений live более 15 секунд"), 15_000)
|
||||
}
|
||||
awaitSample()
|
||||
|
||||
let buf = ""
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: withAuthHeaders({ Accept: "text/event-stream" }),
|
||||
signal: ac.signal,
|
||||
credentials: "include",
|
||||
})
|
||||
if (ac.signal.aborted) return
|
||||
if (!res.ok || !res.body) {
|
||||
setError(`live HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
while (!ac.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (ac.signal.aborted) return
|
||||
if (done) { setError("Поток live завершён"); break }
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split(/\r?\n\r?\n/)
|
||||
buf = parts.pop() ?? ""
|
||||
for (const raw of parts) {
|
||||
if (!raw.trim()) continue
|
||||
const ev = parseSseBlock(raw)
|
||||
if (ev.event === "sample" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as unknown
|
||||
if (!isValidFlowLiveSample(parsed)) {
|
||||
throw new Error("Некорректные данные live-потока")
|
||||
}
|
||||
awaitSample()
|
||||
setResult({ key, sample: parsed, receivedAt: Date.now(), error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null })
|
||||
} else if (ev.event === "error" && ev.data) {
|
||||
const parsed: unknown = JSON.parse(ev.data)
|
||||
throw new Error(parsed && typeof parsed === "object" && "error" in parsed && typeof parsed.error === "string" ? parsed.error : "Ошибка live-потока")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return
|
||||
setError(e instanceof Error ? e.message : "Ошибка live-потока")
|
||||
ac.abort()
|
||||
} finally {
|
||||
stopTimer()
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { stopTimer(); ac.abort() }
|
||||
}, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay, key])
|
||||
|
||||
return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false
|
||||
}
|
||||
return window.innerWidth < MOBILE_BREAKPOINT
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
import { useSyncExternalStore } from "react"
|
||||
const query = "(prefers-reduced-motion: reduce)"
|
||||
function subscribe(callback: () => void) { const media=window.matchMedia(query); media.addEventListener("change",callback); return ()=>media.removeEventListener("change",callback) }
|
||||
export function usePrefersReducedMotion() { return useSyncExternalStore(subscribe,()=>window.matchMedia(query).matches,()=>false) }
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { logStreamSchema, type LogEntry, type LogPage, type LogStream, type LogSummary } from "@mmapp/contracts"
|
||||
import { getLogs, getLogSummary } from "@/shared/api/logs"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
|
||||
export function mergeLogEntries(previous: LogEntry[], incoming: LogEntry[], limit = 5000) {
|
||||
return [...new Map([...previous, ...incoming].map(row => [row.id, row])).values()].sort((a, b) => b.effectiveAt.localeCompare(a.effectiveAt) || (BigInt(a.id) < BigInt(b.id) ? 1 : -1)).slice(0, limit)
|
||||
}
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : String(error)
|
||||
export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null)
|
||||
const [summary, setSummary] = useState<LogSummary | null>(null), [stream, setStream] = useState<LogStream | null>(null)
|
||||
const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null)
|
||||
const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0)
|
||||
const pending = useRef<LogEntry[]>([]), pausedRef = useRef(paused), initialCursor = useRef<string | null>(null)
|
||||
const lifetime = useRef<AbortController | null>(null), moreBusy = useRef(false)
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
const flush = useCallback(() => { const buffered = pending.current; setEntries(prev => mergeLogEntries(prev, buffered).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs)); pending.current = []; setPendingCount(0) }, [windowMs])
|
||||
useEffect(() => {
|
||||
if (!paused && pending.current.length) flush()
|
||||
}, [paused, flush])
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const ac = new AbortController(); lifetime.current = ac
|
||||
queueMicrotask(() => { if (!ac.signal.aborted) { setLoading(true); setError(null) } })
|
||||
const currentQuery = () => { const params = new URLSearchParams(query); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString()); return params }
|
||||
void getLogs(base, currentQuery(), ac.signal).then(data => {
|
||||
if (ac.signal.aborted) return
|
||||
setEntries(data.entries); setPage(data); initialCursor.current = data.watermark
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(message(e)) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
|
||||
const readSummary = () => { void getLogSummary(base, currentQuery(), ac.signal).then(data => { if (!ac.signal.aborted) { setSummary(data); setSummaryError(null) } }).catch(e => { if (!ac.signal.aborted) setSummaryError(message(e)) }) }
|
||||
readSummary()
|
||||
const timer = setInterval(readSummary, 10_000)
|
||||
return () => { ac.abort(); clearInterval(timer); initialCursor.current = null }
|
||||
}, [base, query, enabled, revision, windowMs])
|
||||
|
||||
const ready = page !== null && !loading && !error
|
||||
useEffect(() => {
|
||||
if (!enabled || !live || !ready || !initialCursor.current) return
|
||||
const ac = new AbortController()
|
||||
let cursor = initialCursor.current, retry = 1000
|
||||
void (async () => {
|
||||
while (!ac.signal.aborted) {
|
||||
const connection = new AbortController(), abort = () => connection.abort()
|
||||
ac.signal.addEventListener("abort", abort, { once: true })
|
||||
let watchdog: ReturnType<typeof setTimeout> | undefined
|
||||
const heartbeat = () => { clearTimeout(watchdog); watchdog = setTimeout(() => connection.abort(), 20_000) }
|
||||
try {
|
||||
heartbeat()
|
||||
const params = new URLSearchParams(query); params.set("cursor", cursor); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString())
|
||||
const res = await fetch(resolveApiUrl(base, "/api/logs/live?" + params), { headers: withAuthHeaders({ Accept: "text/event-stream" }), signal: connection.signal, credentials: "include" })
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.json().catch(() => null)
|
||||
throw Object.assign(new Error(body?.error ?? `Live недоступен (HTTP ${res.status})`), { status: res.status })
|
||||
}
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
try {
|
||||
while (!ac.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) throw new Error("Поток завершён; переподключение…")
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""
|
||||
if (buffer.length > 8 * 1024 * 1024) throw new Error("Слишком большой ответ live")
|
||||
for (const block of blocks) {
|
||||
const type = block.match(/^event:\s*(.+)$/m)?.[1]
|
||||
const data = block.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n")
|
||||
if (type === "reset") {
|
||||
const reason = "Поток остановлен для обслуживания БД. После завершения обновите журнал, чтобы заново загрузить историю."
|
||||
if (!ac.signal.aborted) setError(reason)
|
||||
return
|
||||
}
|
||||
if (type === "error") throw new Error(JSON.parse(data).error)
|
||||
if (type !== "batch") continue
|
||||
const batch = logStreamSchema.parse(JSON.parse(data))
|
||||
const expectedServer = new URLSearchParams(query).get("serverId")
|
||||
if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор")
|
||||
heartbeat(); retry = 1000; cursor = batch.cursor
|
||||
if (ac.signal.aborted) return
|
||||
setStream(batch); setStreamError(null)
|
||||
if (batch.entries.length) {
|
||||
if (pausedRef.current) {
|
||||
pending.current = mergeLogEntries(pending.current, batch.entries)
|
||||
setPendingCount(n => n + batch.entries.length)
|
||||
} else setEntries(prev => mergeLogEntries(prev, batch.entries).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs))
|
||||
} else if (!pausedRef.current && windowMs) {
|
||||
setEntries(prev => { const firstExpired = prev.findIndex(row => Date.parse(row.effectiveAt) < Date.now() - windowMs); return firstExpired < 0 ? prev : prev.slice(0, firstExpired) })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}) }
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return
|
||||
setStreamError(message(e))
|
||||
if (e && typeof e === "object" && "status" in e && [401, 403, 404].includes(Number(e.status))) return
|
||||
} finally { clearTimeout(watchdog); connection.abort(); ac.signal.removeEventListener("abort", abort) }
|
||||
await new Promise<void>(resolve => {
|
||||
const done = () => { clearTimeout(timer); ac.signal.removeEventListener("abort", done); resolve() }
|
||||
const timer = setTimeout(done, retry); ac.signal.addEventListener("abort", done, { once: true }); if (ac.signal.aborted) done()
|
||||
})
|
||||
retry = Math.min(15_000, retry * 2)
|
||||
}
|
||||
})()
|
||||
return () => ac.abort()
|
||||
}, [base, query, enabled, live, ready, revision, windowMs])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!page?.nextCursor || moreBusy.current || !enabled) return
|
||||
moreBusy.current = true; setFetchingMore(true)
|
||||
const signal = lifetime.current?.signal
|
||||
try {
|
||||
const params = new URLSearchParams(query); params.set("cursor", page.nextCursor); params.set("watermark", page.watermark)
|
||||
const data = await getLogs(base, params, signal)
|
||||
if (signal?.aborted) return
|
||||
setEntries(prev => mergeLogEntries(prev, data.entries)); setPage(data)
|
||||
} catch (e) { if (!signal?.aborted) setError(message(e)) }
|
||||
finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) }
|
||||
}, [base, query, enabled, page])
|
||||
const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, [])
|
||||
return { entries, summary, stream, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { encodeServerQuery, patchServerUrl, readServerUrl, validateServerQuery, type ServerTypeFilter } from "@/lib/data-filters/server-url-state"
|
||||
|
||||
export function useServerUrlState() {
|
||||
const params = useSearchParams()
|
||||
const raw = params.toString()
|
||||
const state = useMemo(() => readServerUrl(new URLSearchParams(raw)), [raw])
|
||||
const filterKey = params.get("filters")
|
||||
const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null)
|
||||
useEffect(() => {
|
||||
const reset = () => setDraft(null)
|
||||
window.addEventListener("popstate", reset)
|
||||
return () => window.removeEventListener("popstate", reset)
|
||||
}, [])
|
||||
function write(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchServerUrl(new URLSearchParams(window.location.search), changes)
|
||||
if (href === window.location.pathname + window.location.search) return
|
||||
if (replace) window.history.replaceState(null, "", href)
|
||||
else window.history.pushState(null, "", href)
|
||||
}
|
||||
function setFilters(query: FilterQuery) {
|
||||
if (validateServerQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return }
|
||||
try {
|
||||
const encoded = encodeServerQuery(query)
|
||||
setDraft(null)
|
||||
write({ filters: encoded })
|
||||
} catch (error) {
|
||||
setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." })
|
||||
}
|
||||
}
|
||||
const currentDraft = draft?.key === filterKey ? draft : null
|
||||
return {
|
||||
search: state.search,
|
||||
typeFilter: state.type,
|
||||
filters: currentDraft?.query ?? state.query,
|
||||
error: state.error ?? currentDraft?.error ?? null,
|
||||
hasFilters: params.has("q") || params.has("type") || params.has("filters") || currentDraft !== null,
|
||||
setSearch: (value: string) => write({ q: value }, true),
|
||||
setTypeFilter: (value: ServerTypeFilter) => write({ type: value === "all" ? null : value }),
|
||||
setFilters,
|
||||
reset: () => { setDraft(null); write({ q: null, type: null, filters: null }) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
|
||||
export interface TrafficLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
timestampKind?: "backend-receipt"
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string): { event: string; data: string } {
|
||||
let event = "message"
|
||||
const dataLines: string[] = []
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim()
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
return { event, data: dataLines.join("\n") }
|
||||
}
|
||||
|
||||
export function useTrafficLive(opts: {
|
||||
enabled: boolean
|
||||
backendUrl: string
|
||||
serverId: string
|
||||
iface: string
|
||||
reloadKey?: number
|
||||
}): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } {
|
||||
const scope = JSON.stringify([opts.backendUrl, opts.serverId, opts.iface])
|
||||
const key = JSON.stringify([opts.enabled, scope, opts.reloadKey ?? 0])
|
||||
const [result, setResult] = useState<{ key: string; scope: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled || !opts.serverId) return
|
||||
const ac = new AbortController()
|
||||
let lastStreamError: string | null = null
|
||||
// A new connection owns the status; the same device's previous sample remains historical.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null,
|
||||
receivedAt: previous?.scope === scope ? previous.receivedAt : null, error: "Ожидание нового live-измерения" }))
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const stopTimer = () => clearTimeout(sampleTimer)
|
||||
const setError = (error: string) => {
|
||||
lastStreamError = error
|
||||
stopTimer()
|
||||
if (!ac.signal.aborted) setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null, receivedAt: previous?.scope === scope ? previous.receivedAt : null, error }))
|
||||
}
|
||||
const ifaceQ =
|
||||
opts.iface && opts.iface !== "__all__"
|
||||
? `?iface=${encodeURIComponent(opts.iface)}`
|
||||
: ""
|
||||
const path = `/api/traffic/servers/${encodeURIComponent(opts.serverId)}/live${ifaceQ}`
|
||||
const url = resolveApiUrl(opts.backendUrl, path)
|
||||
// Backend ticks every 1.5–2s; tolerate several missed samples. Heartbeats do not reset this timer.
|
||||
const awaitSample = () => {
|
||||
stopTimer()
|
||||
sampleTimer = setTimeout(() => setError("Нет обновлений live более 15 секунд"), 15_000)
|
||||
}
|
||||
awaitSample()
|
||||
|
||||
let buf = ""
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: withAuthHeaders({ Accept: "text/event-stream" }),
|
||||
signal: ac.signal,
|
||||
credentials: "include",
|
||||
})
|
||||
if (ac.signal.aborted) return
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.json().catch(() => null) as { error?: unknown } | null
|
||||
setError(`${typeof body?.error === "string" ? body.error : "Live-поток недоступен"} (HTTP ${res.status})`)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
while (!ac.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (ac.signal.aborted) return
|
||||
if (done) {
|
||||
setError(lastStreamError ?? "Поток live завершён")
|
||||
break
|
||||
}
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split(/\r?\n\r?\n/)
|
||||
buf = parts.pop() ?? ""
|
||||
for (const raw of parts) {
|
||||
if (!raw.trim()) continue
|
||||
const ev = parseSseBlock(raw)
|
||||
if (ev.event === "sample" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as TrafficLiveSample
|
||||
if (!parsed || !Number.isFinite(parsed.rxMbps) || !Number.isFinite(parsed.txMbps) || parsed.rxMbps < 0 || parsed.txMbps < 0 || typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at)) || parsed.timestampKind !== undefined && parsed.timestampKind !== "backend-receipt") {
|
||||
throw new Error("Некорректные данные live-потока")
|
||||
}
|
||||
awaitSample()
|
||||
lastStreamError = null
|
||||
setResult({ key, scope, sample: parsed, receivedAt: Date.now(), error: null })
|
||||
} else if (ev.event === "error" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as { error?: unknown } | null
|
||||
setError(typeof parsed?.error === "string" ? parsed.error : "Некорректный ответ об ошибке live-потока")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return
|
||||
setError(e instanceof Error ? e.message : "live error")
|
||||
} finally {
|
||||
stopTimer()
|
||||
ac.abort()
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { stopTimer(); ac.abort() }
|
||||
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key, scope])
|
||||
|
||||
return opts.enabled && result?.scope === scope ? { sample: result.sample, error: result.key === key ? result.error : "Ожидание нового live-измерения", receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readUptimeUrl, readResourceUrl, patchUptimeUrl, type ResourceTypeFilter, type UptimeTab, type ProbeStatusFilter, type UrlSetter } from "@/lib/data-filters/uptime-url-state"
|
||||
|
||||
export function useUptimeUrlState() {
|
||||
const raw = useSearchParams().toString()
|
||||
const state = useMemo(() => readUptimeUrl(new URLSearchParams(raw)), [raw])
|
||||
const resource = useMemo(() => readResourceUrl(new URLSearchParams(raw)), [raw])
|
||||
function write(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchUptimeUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||
if (href === window.location.pathname + window.location.search + window.location.hash) return
|
||||
if (replace) window.history.replaceState(null, "", href)
|
||||
else window.history.pushState(null, "", href)
|
||||
}
|
||||
const setStatusFilter: UrlSetter<ProbeStatusFilter> = value => {
|
||||
const current = readUptimeUrl(new URLSearchParams(window.location.search)).status
|
||||
const next = typeof value === "function" ? value(current) : value
|
||||
write({ status: next === "all" ? null : next })
|
||||
}
|
||||
const setServerFilter: UrlSetter<string[]> = value => {
|
||||
const current = readUptimeUrl(new URLSearchParams(window.location.search)).servers
|
||||
const next = typeof value === "function" ? value(current) : value
|
||||
write({ servers: next.length ? JSON.stringify(next) : null })
|
||||
}
|
||||
return {
|
||||
resource,
|
||||
setResourceSearch: (rq: string) => write({ rq }, true),
|
||||
setResourceType: (type: ResourceTypeFilter) => write({ rtype: type === "all" ? null : type }),
|
||||
resetResources: () => write({ rq: null, rtype: null }),
|
||||
tab: state.tab, search: state.search, statusFilter: state.status, serverFilter: state.servers, error: state.error,
|
||||
setTab: (tab: UptimeTab) => write({ tab: tab === "probes" ? null : tab }),
|
||||
setSearch: (q: string) => write({ q }, true), setStatusFilter, setServerFilter,
|
||||
resetFilters: () => write({ q: null, status: null, servers: null }),
|
||||
resetUrl: () => write({ tab: null, q: null, status: null, servers: null }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { useMemo } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readWireGuardUrl, patchWireGuardUrl, type WireGuardTab, type WireGuardStatus } from "@/lib/wireguard-url-state"
|
||||
export function useWireGuardUrlState() {
|
||||
const raw = useSearchParams().toString()
|
||||
const state = useMemo(() => readWireGuardUrl(new URLSearchParams(raw)), [raw])
|
||||
function write(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchWireGuardUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||
if (href === window.location.pathname + window.location.search + window.location.hash) return
|
||||
if (replace) window.history.replaceState(null, "", href)
|
||||
else window.history.pushState(null, "", href)
|
||||
}
|
||||
return { ...state, setTab: (tab: WireGuardTab) => write({tab:tab === "interfaces" ? null : tab}),
|
||||
setSearch: (q: string) => write({q}, true), setStatus: (status: WireGuardStatus) => write({status:status === "all" ? null : status}),
|
||||
resetFilters: () => write({q:null,status:null,tab:null}),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user