feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+37
View File
@@ -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&&current())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}
}
+44
View File
@@ -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}
}
+34
View File
@@ -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)&&current())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}
}
+78
View File
@@ -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 }
}
+49
View File
@@ -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 }) },
}
}
+49
View File
@@ -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 }) },
}
}
+650
View File
@@ -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,
}
}
+417
View File
@@ -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]
}
+115
View File
@@ -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 }
}
+23
View File
@@ -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
}
+5
View File
@@ -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) }
+123
View File
@@ -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 }
}
+47
View File
@@ -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 }) },
}
}
+119
View File
@@ -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 }
}
+38
View File
@@ -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 }),
}
}
+18
View File
@@ -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}),
}
}