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:
+461
-166
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user