fix(uptime): отличать ошибки опроса от измеренных потерь Ping

This commit is contained in:
Denozordec committed 2026-10-12 17:55:16 +07:00
1 parent ca23284486
commit 8fb46b1552
35 files changed
+1582 -78

No files matched your search

+33 -33
View File
@@ -4,7 +4,7 @@ import { publishSpeedProbeSnapshots } from "@/lib/speed-probe-snapshot"
import { parseSpeedRunResponse } from "@/lib/speed-run-response"
import { isSpeedPersistenceUnknown } from "@/lib/speed-persistence-outcome"
import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes"
import { dashboardProbeOverviewSchema, parseDashboardProbes, type DashboardProbe as PingProbe } from "@/lib/dashboard-probes"
import { sameSpeedMeasurement, speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts"
@@ -38,7 +38,6 @@ import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed
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"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
@@ -101,13 +100,15 @@ function jitter(base: number, pct: number) {
return Math.round(Math.max(1, base + (Math.random() - 0.5) * base * pct * 2))
}
function rttColor(rtt: number | null, loss: number): string {
function rttColor(rtt: number | null, loss: number | null): string {
if (loss === null) return "text-muted-foreground"
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
return "text-[var(--status-online-fg)]"
}
function probeSparkColor(status: PingProbe["status"]): string {
if (status === "unknown") return "var(--muted-foreground)"
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
}
@@ -118,14 +119,14 @@ function ProbePingRttDetailChart({
probeName,
target,
}: {
series: number[]
series: Array<number | null>
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))
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : null))
const valid = data.filter((v): v is number => v !== null)
if (valid.length === 0) {
return (
<p className="text-xs text-muted-foreground">
@@ -133,22 +134,25 @@ function ProbePingRttDetailChart({
</p>
)
}
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
const chartPts = data
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 maxVal = Math.max(...valid, 1)
const minVal = Math.min(...valid)
const span = Math.max(1, maxVal - minVal) * 1.08
const y0 = minVal - (span - (maxVal - minVal)) / 2
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 segments: Array<Array<{ value: number; index: number }>> = []
let segment: Array<{ value: number; index: number }> = []
chartPts.forEach((value, index) => {
if (value === null) { segment = []; return }
if (!segment.length) segments.push(segment)
segment.push({ value, index })
})
const gridVals = [0, 0.25, 0.5, 0.75, 1]
const fmt = (v: number) => `${Math.round(v)} мс`
return (
@@ -191,15 +195,9 @@ function ProbePingRttDetailChart({
</g>
)
})}
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
<path
d={lineD}
fill="none"
style={{ stroke }}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
{segments.map((points, index) => points.length === 1
? <circle key={index} cx={xAt(points[0].index)} cy={yAt(points[0].value)} r="2" fill={stroke} />
: <path key={index} d={points.map((point, i) => `${i ? "L" : "M"}${xAt(point.index).toFixed(1)},${yAt(point.value).toFixed(1)}`).join(" ")} fill="none" stroke={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) => (
@@ -224,7 +222,7 @@ function ProbePingRttDetailChart({
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>
<span className="text-muted-foreground">В ряду одно измерение.</span>
)}
</div>
</div>
@@ -408,7 +406,7 @@ function ServerPickerCards({
</div>
{!server.enabled && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
В инвентаре выключен — для ping всё равно можно выбрать, если бекенд достигает REST API.
Сервер выключен. Включите его перед опросом.
</p>
)}
</button>
@@ -1132,7 +1130,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
const overview = dashboardProbeOverviewSchema.safeParse(rawData)
if (!overview.success || !pingProbesWriteSchema.safeParse({ probes: overview.data.probes }).success || !rawData || typeof rawData !== "object" || !Array.isArray((rawData as { resources?: unknown }).resources)) throw new Error("Ответ сервера содержит некорректные Ping-пробы.")
const data = { probes: overview.data.probes, resources: (rawData as { resources: ServerResource[] }).resources }
const data = { probes: parseDashboardProbes(rawData).probes, resources: (rawData as { resources: ServerResource[] }).resources }
setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes)
setPingReceivedAt(Date.now())
@@ -1394,6 +1392,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
up: probes.filter(p => p.status === "up").length,
warn: probes.filter(p => p.status === "warn").length,
down: probes.filter(p => p.status === "down").length,
unknown: probes.filter(p => p.status === "unknown").length,
}), [probes])
const alertCount = useMemo(() =>
@@ -1606,9 +1605,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
target: newTarget.trim(),
filter: newFilter || "—",
rtt: null,
loss: 0,
status: "up",
series: Array(40).fill(0),
loss: null,
status: "unknown",
series: [],
enabled: true,
showOnDashboard: false,
}
@@ -2382,6 +2381,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<StatChip label="OK" value={pingCatalogKnown ? stats.up : "—"} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} />
<StatChip label="Предупреждение" value={pingCatalogKnown ? stats.warn : "—"} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} />
<StatChip label="Недоступен" value={pingCatalogKnown ? stats.down : "—"} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} />
<StatChip label="Нет данных" value={pingCatalogKnown ? stats.unknown : "—"} active={statusFilter === "unknown"} onClick={() => cycleStatusFilter("unknown")} />
<div className="w-px h-4 bg-border mx-1 shrink-0" />
@@ -2548,11 +2548,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
>
<label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle disabled={probeSaving || !canWrite || !pingCatalogReady} checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label>
<div className={probeStyles.status}><StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
/><span className={probeStyles.mobile}>{!p.enabled ? "Отключена" : p.status === "up" ? "Доступна" : p.status === "warn" ? "Предупреждение" : "Недоступна"}</span></div>
/><span className={probeStyles.mobile}>{!p.enabled ? "Отключена" : p.status === "unknown" ? "Нет данных" : p.status === "up" ? "Доступна" : p.status === "warn" ? "Предупреждение" : "Недоступна"}</span></div>
<div className={probeStyles.iface}><span className={probeStyles.label}>Интерфейс</span><span className="font-mono text-xs text-muted-foreground">{p.srcInterface || "auto"}</span></div>
<div className={probeStyles.name}><span className="text-sm font-medium">{p.name}</span></div>
<div className={probeStyles.name}><span className="text-sm font-medium">{p.name}</span>{p.observationError && <p className="text-xs text-muted-foreground break-words">Ошибка измерения: {p.observationError}</p>}</div>
<div className={probeStyles.filter}><span className={probeStyles.label}>Фильтр</span>{p.filter === "—"
? <span className="text-muted-foreground/40 text-xs">—</span>
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400">{p.filter}</span>
@@ -2561,12 +2561,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{p.rtt === null ? "—" : `${p.rtt}мс`}
</span></div>
<div className={probeStyles.loss}><span className={probeStyles.label}>Потери</span><span className={cn("font-mono text-xs tabular-nums",
p.loss === 0 ? "text-muted-foreground" :
p.loss === null || p.loss === 0 ? "text-muted-foreground" :
p.loss >= 100 ? "text-red-600 dark:text-red-400" :
p.loss > 5 ? "text-red-600 dark:text-red-400" :
"text-amber-600 dark:text-amber-400",
)}>
{p.loss}%
{p.loss === null ? "—" : `${p.loss}%`}
</span></div>
<div className={cn(probeStyles.bt, "min-w-0 flex flex-col gap-0.5 justify-center")}><span className={probeStyles.label}>BT · ping</span>
{!linkedSp?.lastRunAt ? (