chore: synchronize pending app/backend updates and repository hygiene

Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-05-07 12:29:04 +07:00
co-authored by Cursor
parent bdb9b72fac
commit 5f31bb47fb
81 changed files with 11976 additions and 1239 deletions
+461 -166
View File
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
import type { PingProbe } from "@/lib/data"
@@ -29,6 +30,11 @@ import {
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
@@ -85,7 +91,7 @@ function jitter(base: number, pct: number) {
function rttColor(rtt: number | null, loss: number): string {
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
if (loss > 1 || rtt > 60) return "text-[var(--status-degraded-fg)]"
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
return "text-[var(--status-online-fg)]"
}
@@ -93,6 +99,131 @@ function probeSparkColor(status: PingProbe["status"]): string {
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
}
/** Развёрнутый график RTT под мини-спарклайном (та же серия `PingProbe.series`). */
function ProbePingRttDetailChart({
series,
status,
probeName,
target,
}: {
series: number[]
status: PingProbe["status"]
probeName: string
target: string
}) {
const stroke = probeSparkColor(status)
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0))
const valid = data.filter((v) => Number.isFinite(v))
if (valid.length === 0) {
return (
<p className="text-xs text-muted-foreground">
Нет числовых точек RTT для графика (проба «{probeName}» {target}).
</p>
)
}
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
const W = 720
const H = 168
const pad = { l: 48, r: 14, t: 14, b: 36 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...chartPts, 1)
const minVal = Math.min(...chartPts)
const span = Math.max(1, maxVal - minVal) * 1.08
const y0 = minVal - (span - (maxVal - minVal)) / 2
const y1 = y0 + span
const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw)
const yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih
const lineD = chartPts
.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`)
.join(" ")
const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z`
const gridVals = [0, 0.25, 0.5, 0.75, 1]
const fmt = (v: number) => `${Math.round(v)} мс`
return (
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{probeName}</span>
<span className="font-mono ml-1.5">{target}</span>
</p>
<p className="text-[11px] text-muted-foreground">Ось X: старые замеры слева новые справа · обзор ~1 ч</p>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-[720px] h-[min(200px,42vw)] min-h-[140px]"
style={{ display: "block" }}
preserveAspectRatio="xMidYMid meet"
>
{gridVals.map((g, i) => {
const y = pad.t + ih * (1 - g)
return (
<g key={i}>
<line
x1={pad.l}
x2={W - pad.r}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeDasharray={g === 0 ? "0" : "2 5"}
/>
<text
x={pad.l - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{fmt(y0 + span * g)}
</text>
</g>
)
})}
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
<path
d={lineD}
fill="none"
style={{ stroke }}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
.filter((i, idx, a) => a.indexOf(i) === idx)
.map((i) => (
<text
key={`x-${i}`}
x={xAt(i)}
y={H - 10}
textAnchor="middle"
fontSize="10"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"}
</text>
))}
</svg>
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
<span>
min <span className="font-mono text-foreground">{Math.round(minVal)}</span> мс
</span>
<span>
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
</span>
{valid.length < 2 && (
<span className="text-amber-600 dark:text-amber-400">В ряду одна точка линия для наглядности продублирована.</span>
)}
</div>
</div>
)
}
function probeGroupActionKey(srvId: string, group: { name: string; target: string }) {
return `${srvId}\t${group.name}\t${group.target}`
}
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -313,6 +444,11 @@ function ServerPickerCards({
<span className="text-muted-foreground/30"></span>
<span className="truncate">{server.host}</span>
</div>
{!server.enabled && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
В инвентаре выключен для ping всё равно можно выбрать, если бекенд достигает REST API.
</p>
)}
</button>
)
})
@@ -553,6 +689,8 @@ function LinkedFilterPickerCards({
interface ServerResource {
serverId: string
/** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */
hasData?: boolean
cpu: number
cpuHistory: number[]
ramUsed: number // MB
@@ -577,6 +715,24 @@ interface BackendServer {
os: string | null
}
function mapBackendServersToServers(data: BackendServer[]): Server[] {
return data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
}
interface SpeedTestRun {
id: string
startedAt: number
@@ -620,6 +776,25 @@ interface SpeedProbeRow {
lastPingError?: string | null
}
/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */
function orphanSpeedSourceStub(id: string): Server {
return {
id,
name: `Нет в каталоге (#${id})`,
host: "—",
model: "—",
os: "—",
site: "—",
country: "UN",
asn: "",
type: "home-router",
enabled: false,
status: "offline",
latency: null,
sessions: 0,
}
}
function stripIpCidr(addr: string): string {
const t = addr.trim()
if (!t) return ""
@@ -663,7 +838,7 @@ function findLinkedSpeedProbe(
function fmtMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
return `${mb} МБ`
return `${mb.toFixed(1)} МБ`
}
function fmtUptime(sec: number): string {
@@ -714,6 +889,7 @@ const INIT_RESOURCES: ServerResource[] = mockServers.map(s => {
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
hasData: true,
}
})
@@ -747,30 +923,36 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; serversList: Server[] }) {
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map(r => ({
...r,
server: serversList.find(s => s.id === r.serverId),
ramPct: Math.round(r.ramUsed / r.ramTotal * 100),
hddPct: Math.round(r.hddUsed / r.hddTotal * 100),
})).filter(r => r.server !== undefined), [resources, serversList])
const rows = useMemo(() => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
// KPI aggregates
const online = rows.filter(r => r.server!.status === "online")
const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0
const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hddPct >= 85).length
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
@@ -998,7 +1180,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const isCrit = !offline && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
@@ -1016,21 +1200,26 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{r.boardName}</span>
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
@@ -1046,8 +1235,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* RAM */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1063,8 +1252,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* HDD */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1081,13 +1270,13 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{offline ? "—" : fmtUptime(r.uptimeSeconds)}
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !offline ? (
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
@@ -1108,7 +1297,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
</Card>
<p className="text-xs text-muted-foreground/40 text-center">
Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим
{liveApi
? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend"
: "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}
</p>
</div>
)
@@ -1118,8 +1309,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
export default function UptimePage() {
const [allServers, setAllServers] = useState<Server[]>(mockServers)
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
const liveApi = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
@@ -1132,6 +1324,11 @@ export default function UptimePage() {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [sheetOpen, setSheetOpen] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false)
/** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */
const [probeGroupPingBusy, setProbeGroupPingBusy] = useState<Record<string, boolean>>({})
/** Раскрытый подробный график RTT по id пробы */
const [probeRttChartOpen, setProbeRttChartOpen] = useState<Record<string, boolean>>({})
const [speedBusy, setSpeedBusy] = useState(false)
const [speedError, setSpeedError] = useState<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
@@ -1153,37 +1350,18 @@ export default function UptimePage() {
})
useEffect(() => {
if (!isLive) {
if (!liveApi) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
return
}
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped: Server[] = data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
setAllServers(mapped)
})
.catch(() => setAllServers([]))
}, [isLive, apiFetch])
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
}, [liveApi])
useEffect(() => {
if (isLive) return
if (liveApi) return
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
}, [isLive])
}, [liveApi])
const isPausedRef = useRef(isPaused)
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
@@ -1192,82 +1370,137 @@ export default function UptimePage() {
const overviewReqRef = useRef(0)
const loadLiveOverview = useCallback(async () => {
if (!isLive) return
if (!liveApi) return
const myReq = ++overviewReqRef.current
setOpError(null)
try {
const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h")
const [serverRows, data] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
])
if (myReq !== overviewReqRef.current) return
setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes)
setResources(data.resources)
} catch (e) {
if (myReq !== overviewReqRef.current) return
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
}
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const reloadSpeedData = useCallback(async () => {
if (!liveApi) return
type RunRow = {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}
try {
const [sp, runsRes] = await Promise.all([
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
])
setSpeedProbes(sp.probes ?? [])
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
} catch {
setSpeedProbes([])
setSpeedRuns([])
}
}, [apiFetch, liveApi])
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
if (!liveApi) return
if (opts?.showSpinner) setUptimeRefreshBusy(true)
let collectErr: string | null = null
try {
void checkBackend()
if (opts?.pollDevices) {
try {
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
} catch (e) {
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
}
}
await Promise.all([loadLiveOverview(), reloadSpeedData()])
if (collectErr) {
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
}
} finally {
if (opts?.showSpinner) setUptimeRefreshBusy(false)
}
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
const refreshProbeGroupPings = useCallback(
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
if (!liveApi) return
const key = probeGroupActionKey(srvId, group)
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
setOpError(null)
try {
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
method: "POST",
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
})
await loadLiveOverview()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
} finally {
setProbeGroupPingBusy((m) => {
const next = { ...m }
delete next[key]
return next
})
}
},
[liveApi, apiFetch, loadLiveOverview],
)
useEffect(() => {
if (!isLive) return
queueMicrotask(() => { void loadLiveOverview() })
}, [isLive, loadLiveOverview])
if (!liveApi) return
queueMicrotask(() => { void refreshUptimeLive() })
}, [liveApi, refreshUptimeLive])
useEffect(() => {
if (!isLive) return
void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
.then((data) => setSpeedProbes(data.probes ?? []))
.catch(() => setSpeedProbes([]))
}, [isLive, apiFetch])
useEffect(() => {
if (!isLive) return
void apiFetch<{
runs: Array<{
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}>
}>("/api/uptime/speed-test/runs")
.then((data) => {
const rows = (data.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
})
.catch(() => setSpeedRuns([]))
}, [isLive, apiFetch])
if (!liveApi || isPaused) return
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
return () => clearInterval(id)
}, [liveApi, isPaused, refreshUptimeLive])
// add-probe form
const [newSrcId, setNewSrcId] = useState("")
@@ -1279,13 +1512,11 @@ export default function UptimePage() {
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
const selectableSources = useMemo(
() => allServers.filter(s => s.enabled),
[allServers],
)
/** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */
const selectableSources = useMemo(() => allServers, [allServers])
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
if (!isLive) return
if (!liveApi) return
if (!serverId || speedIfaces[serverId]) return
const id = Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return
@@ -1295,7 +1526,7 @@ export default function UptimePage() {
} catch {
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
}
}, [apiFetch, isLive, speedIfaces])
}, [apiFetch, liveApi, speedIfaces])
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
useEffect(() => {
@@ -1333,7 +1564,7 @@ export default function UptimePage() {
setNewSrcInterface("")
return
}
if (!isLive) {
if (!liveApi) {
setSrcInterfaces([])
setNewSrcInterface("")
return
@@ -1356,22 +1587,23 @@ export default function UptimePage() {
setNewSrcInterface("")
})
.finally(() => setSrcInterfacesBusy(false))
}, [sheetOpen, newSrcId, isLive, apiFetch])
}, [sheetOpen, newSrcId, liveApi, apiFetch])
// servers that have at least one probe (preserve data-order)
const probedServerIds = useMemo(
() => [...new Set(probes.map(p => p.srcServerId))],
[probes],
)
const probedServers = useMemo(
() => allServers.filter(s => probedServerIds.includes(s.id)),
[probedServerIds, allServers],
)
const probedServers = useMemo(() => {
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
}, [probedServerIds, allServers])
// live RTT tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setProbes(prev => prev.map(p => {
if (!p.enabled || p.status === "down" || p.rtt === null) return p
@@ -1381,16 +1613,17 @@ export default function UptimePage() {
}))
}, 3000)
return () => clearInterval(id)
}, [isLive])
}, [liveApi])
// live resource tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setResources(prev => prev.map(r => {
const srv = allServers.find(s => s.id === r.serverId)
if (!srv || srv.status !== "online") return r
if (r.hasData === false) return r
const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8)))
const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
const newTemp = r.temp !== undefined
@@ -1407,7 +1640,7 @@ export default function UptimePage() {
}))
}, 5000)
return () => clearInterval(id)
}, [isLive, allServers])
}, [liveApi, allServers])
// ── derived ──
const stats = useMemo(() => ({
@@ -1420,7 +1653,7 @@ export default function UptimePage() {
const alertCount = useMemo(() =>
resources.filter(r => {
const s = allServers.find(x => x.id === r.serverId)
if (!s || s.status !== "online") return false
if (!s || s.status !== "online" || r.hasData === false) return false
const ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
@@ -1467,16 +1700,19 @@ export default function UptimePage() {
arr.push(p)
map.set(p.srcServerId, arr)
}
return selectableSources
.filter((s) => map.has(s.id))
.map((s) => ({
server: s,
probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, selectableSources])
const ids = [...map.keys()].sort((a, b) => {
const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a)
const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b)
return (sa.host + sa.name).localeCompare(sb.host + sb.name)
})
return ids.map((id) => ({
server: allServers.find((s) => s.id === id) ?? orphanSpeedSourceStub(id),
probes: (map.get(id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, allServers])
const persistProbes = useCallback((rows: PingProbe[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/probes", {
method: "PUT",
body: JSON.stringify({
@@ -1492,17 +1728,17 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const toggleDashboardStar = useCallback((id: string) => {
const cur = probes.find((p) => p.id === id)
if (!cur) return
const nextVal = !cur.showOnDashboard
if (isLive) {
if (liveApi) {
overviewReqRef.current += 1
}
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
if (isLive) {
if (liveApi) {
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -1526,10 +1762,10 @@ export default function UptimePage() {
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
}
}
}, [probes, isLive, apiFetch, loadLiveOverview])
}, [probes, liveApi, apiFetch, loadLiveOverview])
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/speed-probes", {
method: "PUT",
body: JSON.stringify({
@@ -1551,7 +1787,7 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
// ── actions ──
const toggleProbe = (id: string, v: boolean) =>
@@ -1562,7 +1798,7 @@ export default function UptimePage() {
})
const deleteProbe = (id: string) => {
if (!isLive) {
if (!liveApi) {
const s = readMockDashboardStarIds()
s.delete(id)
writeMockDashboardStarIds(s)
@@ -1747,7 +1983,7 @@ export default function UptimePage() {
lines: ["status: running..."],
}, ...prev].slice(0, 20))
try {
if (isLive) {
if (liveApi) {
const payload = {
runId,
probeId: probe.id,
@@ -1974,15 +2210,15 @@ export default function UptimePage() {
: <><PauseIcon className="size-4" />Пауза</>}
</Button>
<Button variant="outline" size="sm" onClick={() => {
if (isLive) {
void loadLiveOverview()
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
if (liveApi) {
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
return
}
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}}>
<RefreshCwIcon className="size-4" />{isLive ? "Обновить" : "Сбросить"}
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
</Button>
{tab === "probes" && (
@@ -2004,9 +2240,11 @@ export default function UptimePage() {
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
{isLive
? "Live (backend)"
: "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
{liveApi
? (backendStatus === false
? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются"
: "Live (backend)")
: "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
</div>
)}
{isPaused && (
@@ -2066,7 +2304,7 @@ export default function UptimePage() {
<div className="flex-1 overflow-y-auto">
{/* ── resources tab ── */}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} />}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} liveApi={liveApi} />}
{/* ── speed tab ── */}
{tab === "speed" && (
@@ -2552,13 +2790,30 @@ export default function UptimePage() {
{!isCollapsed && (
<>
<div className="divide-y divide-border/60">
{probeGroups.map((group) => (
{probeGroups.map((group) => {
const groupBusyKey = probeGroupActionKey(srv.id, group)
const groupBusy = !!probeGroupPingBusy[groupBusyKey]
return (
<div key={`${group.name}|${group.target}`}>
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
<ArrowRightIcon className="size-3.5 text-muted-foreground/60" />
<span className="font-medium truncate">{group.name}</span>
<span className="font-mono text-muted-foreground">{group.target}</span>
<span className="ml-auto text-muted-foreground">{group.probes.length} интерф.</span>
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium truncate min-w-0">{group.name}</span>
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{group.probes.length} интерф.</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!liveApi || groupBusy}
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
>
<RefreshCwIcon className={cn("size-3", groupBusy && "animate-spin")} />
Обновить
</Button>
</div>
</div>
<div className="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
@@ -2579,7 +2834,8 @@ export default function UptimePage() {
{group.probes.map((p) => {
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
return (
<div key={p.id}
<div key={p.id} className="border-b border-border/40 last:border-b-0">
<div
className={cn(
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!p.enabled && "opacity-40",
@@ -2675,6 +2931,41 @@ export default function UptimePage() {
</Button>
</div>
</div>
<Collapsible
open={probeRttChartOpen[p.id] ?? false}
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 bg-muted/15 px-4 py-1.5 text-left text-xs text-muted-foreground",
"outline-none hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
probeRttChartOpen[p.id] && "rotate-180",
)}
/>
<span>
Подробный график RTT
<span className="font-mono tabular-nums text-muted-foreground/80 ml-1">
({p.series.length} точ.)
</span>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-muted/5 px-4 py-3">
<ProbePingRttDetailChart
series={p.series}
status={p.status}
probeName={p.name}
target={group.target}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
@@ -2691,7 +2982,8 @@ export default function UptimePage() {
Добавить интерфейс для {group.name} ({group.target})
</button>
</div>
))}
)
})}
</div>
</>
)}
@@ -2894,9 +3186,12 @@ export default function UptimePage() {
</div>
</div>
<Field label="Источник (кто пингует)">
<Field
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
<ServerPickerCards
options={allServers.filter((s) => s.enabled)}
options={selectableSources}
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>