fix(uptime): отличать ошибки опроса от измеренных потерь Ping
This commit is contained in:
1 parent
ca23284486
commit
8fb46b1552
35 files changed
+1582
-78
No files matched your search
+33
-33
@@ -4,7 +4,7 @@ import { publishSpeedProbeSnapshots } from "@/lib/speed-probe-snapshot"
|
|||||||
import { parseSpeedRunResponse } from "@/lib/speed-run-response"
|
import { parseSpeedRunResponse } from "@/lib/speed-run-response"
|
||||||
import { isSpeedPersistenceUnknown } from "@/lib/speed-persistence-outcome"
|
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"
|
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 { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
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 { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import {
|
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))
|
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 (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)]"
|
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
|
||||||
return "text-[var(--status-online-fg)]"
|
return "text-[var(--status-online-fg)]"
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeSparkColor(status: PingProbe["status"]): string {
|
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)"
|
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,14 +119,14 @@ function ProbePingRttDetailChart({
|
|||||||
probeName,
|
probeName,
|
||||||
target,
|
target,
|
||||||
}: {
|
}: {
|
||||||
series: number[]
|
series: Array<number | null>
|
||||||
status: PingProbe["status"]
|
status: PingProbe["status"]
|
||||||
probeName: string
|
probeName: string
|
||||||
target: string
|
target: string
|
||||||
}) {
|
}) {
|
||||||
const stroke = probeSparkColor(status)
|
const stroke = probeSparkColor(status)
|
||||||
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0))
|
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : null))
|
||||||
const valid = data.filter((v) => Number.isFinite(v))
|
const valid = data.filter((v): v is number => v !== null)
|
||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -133,22 +134,25 @@ function ProbePingRttDetailChart({
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
|
const chartPts = data
|
||||||
const W = 720
|
const W = 720
|
||||||
const H = 168
|
const H = 168
|
||||||
const pad = { l: 48, r: 14, t: 14, b: 36 }
|
const pad = { l: 48, r: 14, t: 14, b: 36 }
|
||||||
const iw = W - pad.l - pad.r
|
const iw = W - pad.l - pad.r
|
||||||
const ih = H - pad.t - pad.b
|
const ih = H - pad.t - pad.b
|
||||||
const maxVal = Math.max(...chartPts, 1)
|
const maxVal = Math.max(...valid, 1)
|
||||||
const minVal = Math.min(...chartPts)
|
const minVal = Math.min(...valid)
|
||||||
const span = Math.max(1, maxVal - minVal) * 1.08
|
const span = Math.max(1, maxVal - minVal) * 1.08
|
||||||
const y0 = minVal - (span - (maxVal - minVal)) / 2
|
const y0 = minVal - (span - (maxVal - minVal)) / 2
|
||||||
const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw)
|
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 yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih
|
||||||
const lineD = chartPts
|
const segments: Array<Array<{ value: number; index: number }>> = []
|
||||||
.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`)
|
let segment: Array<{ value: number; index: number }> = []
|
||||||
.join(" ")
|
chartPts.forEach((value, index) => {
|
||||||
const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z`
|
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 gridVals = [0, 0.25, 0.5, 0.75, 1]
|
||||||
const fmt = (v: number) => `${Math.round(v)} мс`
|
const fmt = (v: number) => `${Math.round(v)} мс`
|
||||||
return (
|
return (
|
||||||
@@ -191,15 +195,9 @@ function ProbePingRttDetailChart({
|
|||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
|
{segments.map((points, index) => points.length === 1
|
||||||
<path
|
? <circle key={index} cx={xAt(points[0].index)} cy={yAt(points[0].value)} r="2" fill={stroke} />
|
||||||
d={lineD}
|
: <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" />)}
|
||||||
fill="none"
|
|
||||||
style={{ stroke }}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
|
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
|
||||||
.filter((i, idx, a) => a.indexOf(i) === idx)
|
.filter((i, idx, a) => a.indexOf(i) === idx)
|
||||||
.map((i) => (
|
.map((i) => (
|
||||||
@@ -224,7 +222,7 @@ function ProbePingRttDetailChart({
|
|||||||
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
|
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
|
||||||
</span>
|
</span>
|
||||||
{valid.length < 2 && (
|
{valid.length < 2 && (
|
||||||
<span className="text-amber-600 dark:text-amber-400">В ряду одна точка — линия для наглядности продублирована.</span>
|
<span className="text-muted-foreground">В ряду одно измерение.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,7 +406,7 @@ function ServerPickerCards({
|
|||||||
</div>
|
</div>
|
||||||
{!server.enabled && (
|
{!server.enabled && (
|
||||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
|
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
|
||||||
В инвентаре выключен — для ping всё равно можно выбрать, если бекенд достигает REST API.
|
Сервер выключен. Включите его перед опросом.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -1132,7 +1130,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
||||||
const overview = dashboardProbeOverviewSchema.safeParse(rawData)
|
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-пробы.")
|
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))
|
setAllServers(mapBackendServersToServers(serverRows))
|
||||||
setProbes(data.probes)
|
setProbes(data.probes)
|
||||||
setPingReceivedAt(Date.now())
|
setPingReceivedAt(Date.now())
|
||||||
@@ -1394,6 +1392,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
up: probes.filter(p => p.status === "up").length,
|
up: probes.filter(p => p.status === "up").length,
|
||||||
warn: probes.filter(p => p.status === "warn").length,
|
warn: probes.filter(p => p.status === "warn").length,
|
||||||
down: probes.filter(p => p.status === "down").length,
|
down: probes.filter(p => p.status === "down").length,
|
||||||
|
unknown: probes.filter(p => p.status === "unknown").length,
|
||||||
}), [probes])
|
}), [probes])
|
||||||
|
|
||||||
const alertCount = useMemo(() =>
|
const alertCount = useMemo(() =>
|
||||||
@@ -1606,9 +1605,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
target: newTarget.trim(),
|
target: newTarget.trim(),
|
||||||
filter: newFilter || "—",
|
filter: newFilter || "—",
|
||||||
rtt: null,
|
rtt: null,
|
||||||
loss: 0,
|
loss: null,
|
||||||
status: "up",
|
status: "unknown",
|
||||||
series: Array(40).fill(0),
|
series: [],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showOnDashboard: false,
|
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="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.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.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" />
|
<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>
|
<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
|
<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}
|
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.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 === "—"
|
<div className={probeStyles.filter}><span className={probeStyles.label}>Фильтр</span>{p.filter === "—"
|
||||||
? <span className="text-muted-foreground/40 text-xs">—</span>
|
? <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>
|
: <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}мс`}
|
{p.rtt === null ? "—" : `${p.rtt}мс`}
|
||||||
</span></div>
|
</span></div>
|
||||||
<div className={probeStyles.loss}><span className={probeStyles.label}>Потери</span><span className={cn("font-mono text-xs tabular-nums",
|
<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 >= 100 ? "text-red-600 dark:text-red-400" :
|
||||||
p.loss > 5 ? "text-red-600 dark:text-red-400" :
|
p.loss > 5 ? "text-red-600 dark:text-red-400" :
|
||||||
"text-amber-600 dark:text-amber-400",
|
"text-amber-600 dark:text-amber-400",
|
||||||
)}>
|
)}>
|
||||||
{p.loss}%
|
{p.loss === null ? "—" : `${p.loss}%`}
|
||||||
</span></div>
|
</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>
|
<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 ? (
|
{!linkedSp?.lastRunAt ? (
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- A failed command is an attempt, not a measured packet-loss percentage.
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||||
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
|
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
|
||||||
"test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts",
|
"test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts",
|
||||||
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
|
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
|
||||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
|
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ export const uptimeProbeSamples = pgTable("uptime_probe_samples", {
|
|||||||
rttMs: integer("rtt_ms"),
|
rttMs: integer("rtt_ms"),
|
||||||
lossPct: integer("loss_pct").notNull().default(0),
|
lossPct: integer("loss_pct").notNull().default(0),
|
||||||
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
||||||
|
measurementKnown: boolean("measurement_known").notNull().default(true),
|
||||||
|
observationError: text("observation_error"),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
primaryKey({ columns: [t.probeId, t.sampledAt] }),
|
primaryKey({ columns: [t.probeId, t.sampledAt] }),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||||
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
if (!srv.enabled) return reply.status(409).send({ error: "Сервер отключён: включите его перед опросом интерфейсов" })
|
||||||
try {
|
try {
|
||||||
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
|
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
|
||||||
const interfaces = rows
|
const interfaces = rows
|
||||||
@@ -271,6 +272,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||||
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
if (!srv.enabled) return reply.status(409).send({ error: "Сервер отключён: включите его перед опросом интерфейсов" })
|
||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(srv)
|
const client = MikrotikClient.fromServer(srv)
|
||||||
const [ifaces, ips] = await Promise.all([
|
const [ifaces, ips] = await Promise.all([
|
||||||
@@ -322,8 +324,9 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const probes = probeRows.map((p) => {
|
const probes = probeRows.map((p) => {
|
||||||
const rows = probeSamples.filter((r) => r.probeId === p.id)
|
const rows = probeSamples.filter((r) => r.probeId === p.id)
|
||||||
const last = rows[rows.length - 1]
|
const observations=buildUptimeProbeObservations(rows.filter(r=>Date.parse(r.sampledAt)<=Date.parse(rangeEndAt)))
|
||||||
const series = toSeries(rows.map((r) => r.rttMs ?? 0))
|
const last = observations.samples.at(-1)
|
||||||
|
const series = observations.samples.map(r => r.rtt)
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
srcServerId: String(p.srcServerId),
|
srcServerId: String(p.srcServerId),
|
||||||
@@ -331,13 +334,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
target: p.target,
|
target: p.target,
|
||||||
filter: p.probeFilter || "—",
|
filter: p.probeFilter || "—",
|
||||||
rtt: last?.rttMs ?? null,
|
rtt: last?.rtt ?? null,
|
||||||
loss: last?.lossPct ?? 100,
|
loss: last?.loss ?? null,
|
||||||
status: (last?.status ?? "down") as "up" | "warn" | "down",
|
status: last?.status ?? "unknown",
|
||||||
series,
|
series,
|
||||||
enabled: p.enabled,
|
enabled: p.enabled,
|
||||||
showOnDashboard: p.showOnDashboard,
|
showOnDashboard: p.showOnDashboard,
|
||||||
observations:buildUptimeProbeObservations(rows.filter(r=>Date.parse(r.sampledAt)<=Date.parse(rangeEndAt))),
|
observations,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ assert.equal(run({ resources: [row("uptime_resources", [resource("online")]), ro
|
|||||||
const due = [row("uptime_ping", [probe("fast")]), row("uptime_ping", [], -15_000), row("uptime_ping", [probe()], -90_000)]
|
const due = [row("uptime_ping", [probe("fast")]), row("uptime_ping", [], -15_000), row("uptime_ping", [probe()], -90_000)]
|
||||||
assert.deepEqual(run({ ping: due }).probes.map(p => p.key).sort(), ["fast → target", "slow → target"])
|
assert.deepEqual(run({ ping: due }).probes.map(p => p.key).sort(), ["fast → target", "slow → target"])
|
||||||
assert.deepEqual(run({ ping: [{ finishedAt: iso(), resultJson: null }, ...due] }).probes, [])
|
assert.deepEqual(run({ ping: [{ finishedAt: iso(), resultJson: null }, ...due] }).probes, [])
|
||||||
|
for (const malformed of [null, {}, {probeId:""}]) assert.deepEqual(run({ ping: [row("uptime_ping", [malformed]), ...due] }).probes, [])
|
||||||
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -360_001)] }).probes, [])
|
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -360_001)] }).probes, [])
|
||||||
assert.equal(run({ ping: [row("uptime_ping", [probe()], -360_000)] }).probes.length, 1)
|
assert.equal(run({ ping: [row("uptime_ping", [probe()], -360_000)] }).probes.length, 1)
|
||||||
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], 15_001)] }).probes, [])
|
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], 15_001)] }).probes, [])
|
||||||
@@ -77,7 +78,8 @@ assert.deepEqual(run({ ping: [row("uptime_ping", [probe(), probe()])] }).probes,
|
|||||||
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [{ ...config.probes[0], enabled: false }] }).probes, [])
|
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [{ ...config.probes[0], enabled: false }] }).probes, [])
|
||||||
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [config.probes[0], { ...config.probes[0], id: "duplicate" }] }).probes, [])
|
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [config.probes[0], { ...config.probes[0], id: "duplicate" }] }).probes, [])
|
||||||
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -1_000)] }, { ...config, servers: [{ ...config.servers[0], updatedAt: iso() }] }).probes, [])
|
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -1_000)] }, { ...config, servers: [{ ...config.servers[0], updatedAt: iso() }] }).probes, [])
|
||||||
assert.equal(run({ ping: [row("uptime_ping", [{ ...probe(), ok: false, status: "down", rttMs: null, lossPct: 100 }])] }).probes[0].status, "down")
|
assert.deepEqual(run({ ping: [row("uptime_ping", [{ ...probe(), ok: false, status: "down", rttMs: null, lossPct: 100 }]), row("uptime_ping", [probe()], -15_000)] }).probes, [])
|
||||||
|
assert.equal(run({ ping: [row("uptime_ping", [{ ...probe(), ok: true, status: "down", rttMs: null, lossPct: 100 }])] }).probes[0].status, "down")
|
||||||
assert.equal(probePeriodMs(config, { ...config.probes[0], intervalSec: 1 }), 15_000)
|
assert.equal(probePeriodMs(config, { ...config.probes[0], intervalSec: 1 }), 15_000)
|
||||||
|
|
||||||
// Suppressed measurements do not fire real evaluation paths (offline, loss, low traffic).
|
// Suppressed measurements do not fire real evaluation paths (offline, loss, low traffic).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function parseRun(row: CollectorRunRow, job: string, field: "servers" | "probes"
|
|||||||
if (typeof value === "string") { try { value = JSON.parse(value) } catch { return null } }
|
if (typeof value === "string") { try { value = JSON.parse(value) } catch { return null } }
|
||||||
if (!record(value) || value.v !== 1 || value.job !== job || value.skipped === true || value.fatalError
|
if (!record(value) || value.v !== 1 || value.job !== job || value.skipped === true || value.fatalError
|
||||||
|| typeof value.sampledAt !== "string" || !Array.isArray(value[field])) return null
|
|| typeof value.sampledAt !== "string" || !Array.isArray(value[field])) return null
|
||||||
|
if (field === "probes" && value[field].some(row => !record(row) || typeof row.probeId !== "string" || !row.probeId.trim())) return null
|
||||||
const sampledMs = Date.parse(value.sampledAt), finishedMs = new Date(row.finishedAt).getTime()
|
const sampledMs = Date.parse(value.sampledAt), finishedMs = new Date(row.finishedAt).getTime()
|
||||||
if (!Number.isFinite(sampledMs) || !Number.isFinite(finishedMs)) return null
|
if (!Number.isFinite(sampledMs) || !Number.isFinite(finishedMs)) return null
|
||||||
return { sampledAt: value.sampledAt, sampledMs, finishedMs, rows: value[field].filter(record) }
|
return { sampledAt: value.sampledAt, sampledMs, finishedMs, rows: value[field].filter(record) }
|
||||||
@@ -104,9 +105,8 @@ function probeSignals(config: CollectorSourceConfig, rows: CollectorRunRow[], no
|
|||||||
const row = matches[0]
|
const row = matches[0]
|
||||||
if (row.name !== probe.name || row.target !== probe.target || row.srcServerId !== probe.srcServerId
|
if (row.name !== probe.name || row.target !== probe.target || row.srcServerId !== probe.srcServerId
|
||||||
|| row.srcInterface !== probe.srcInterface || row.srcServerName !== (source.name || source.host)
|
|| row.srcInterface !== probe.srcInterface || row.srcServerName !== (source.name || source.host)
|
||||||
|| typeof row.status !== "string" || !["up", "warn", "down"].includes(row.status) || typeof row.ok !== "boolean"
|
|| typeof row.status !== "string" || !["up", "warn", "down"].includes(row.status) || row.ok !== true
|
||||||
|| !(row.rttMs === null || finiteNonnegative(row.rttMs)) || !finiteNonnegative(row.lossPct) || row.lossPct > 100) break
|
|| !(row.rttMs === null || finiteNonnegative(row.rttMs)) || !finiteNonnegative(row.lossPct) || row.lossPct > 100) break
|
||||||
if (row.ok === false && (row.status !== "down" || row.rttMs !== null || row.lossPct !== 100)) break
|
|
||||||
out.push({ key: label(probe), status: row.status as ProbeSignal["status"], rttMs: row.rttMs as number | null, lossPct: row.lossPct, sampledAt: run.sampledAt })
|
out.push({ key: label(probe), status: row.status as ProbeSignal["status"], rttMs: row.rttMs as number | null, lossPct: row.lossPct, sampledAt: run.sampledAt })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { createServer } from "node:http"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||||
|
import { pool, closePool } from "../db/index.js"
|
||||||
|
import { initDatabase } from "../db/bootstrap.js"
|
||||||
|
import { collectPingProbesOnce, collectPingForProbeIds, readProbeSamplesSince } from "./uptime-collector.js"
|
||||||
|
import { buildUptimeProbeObservations } from "./uptime-probe-observations.js"
|
||||||
|
import { buildSignalSnapshotFromCollectors } from "./alert-engine/signals-from-collectors.js"
|
||||||
|
import uptimeRoutes from "../routes/uptime.js"
|
||||||
|
|
||||||
|
const database = process.env.MMAPP_OWN_SQL_DB
|
||||||
|
if (!database) console.log("ping measurement SQL: skipped; explicit owned database required")
|
||||||
|
else {
|
||||||
|
assert(/^mm_accept_166_\d+$/.test(database));assert.equal(new URL(process.env.DATABASE_URL!).pathname,"/"+database)
|
||||||
|
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name,database)
|
||||||
|
const prefix="own-measure173-"+Date.now()+"-",probeId=prefix+"probe"
|
||||||
|
let mode:"deny"|"empty"|"loss"|"zero"="deny",requests=0,serverId:number|undefined,cases=0
|
||||||
|
const http=createServer((req,res)=>{requests++;res.setHeader("Content-Type","application/json");if(mode==="deny"){res.statusCode=401;res.end(JSON.stringify({error:401,message:"Unauthorized"}))}else res.end(JSON.stringify(mode==="empty"?[]:[{sent:"4",received:mode==="loss"?"0":"4","packet-loss":mode==="loss"?"100":"0","avg-rtt":"0ms"}]))})
|
||||||
|
await new Promise<void>(resolve=>http.listen(0,"127.0.0.1",resolve))
|
||||||
|
const address=http.address();assert(address&&typeof address==="object")
|
||||||
|
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(uptimeRoutes)
|
||||||
|
try {
|
||||||
|
await initDatabase()
|
||||||
|
serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,updated_at) VALUES($1,'127.0.0.1',$2,false,true,'fixture','fixture',$3) RETURNING id",[prefix+"server",address.port,new Date(Date.now()-3_600_000).toISOString()])).rows[0].id)
|
||||||
|
await pool.query("INSERT INTO uptime_settings(id,enabled,resources_enabled,ping_enabled,interval_sec,probe_interval_sec) VALUES(1,true,false,true,15,15) ON CONFLICT(id) DO UPDATE SET enabled=true,resources_enabled=false,ping_enabled=true,interval_sec=15,probe_interval_sec=15")
|
||||||
|
await pool.query("INSERT INTO uptime_probes(id,src_server_id,name,target,interval_sec,updated_at) VALUES($1,$2,$3,'192.0.2.1',1,$4)",[probeId,serverId,prefix+"probe",new Date(Date.now()-3_600_000).toISOString()])
|
||||||
|
const since=new Date(Date.now()-3_600_000).toISOString()
|
||||||
|
const latest=async()=>{const rows=await readProbeSamplesSince(since);return buildUptimeProbeObservations(rows.filter(r=>r.probeId===probeId)).samples.at(-1)!}
|
||||||
|
const snapshot=await collectPingProbesOnce();assert.equal(snapshot.probes[0].ok,false);assert.equal(snapshot.probes[0].status,"unknown");assert.equal(snapshot.probes[0].lossPct,null)
|
||||||
|
let sample=await latest();assert.equal(sample.status,"unknown");assert.equal(sample.loss,null);assert("error" in sample);cases++
|
||||||
|
await pool.query("INSERT INTO scheduler_runs(id,job_key,started_at,finished_at,status,result_json) VALUES($1,'uptime_ping',$2,$3,'error',$4::jsonb)",[prefix+"run",snapshot.sampledAt,new Date().toISOString(),JSON.stringify(snapshot)])
|
||||||
|
assert.deepEqual((await buildSignalSnapshotFromCollectors()).probes,[])
|
||||||
|
let response=await app.inject({method:"GET",url:"/uptime/overview?range=1h"});assert.equal(response.statusCode,200);let dto=response.json().probes.find((p:{id:string})=>p.id===probeId);assert.equal(dto.status,"unknown");assert.equal(dto.loss,null);cases++
|
||||||
|
mode="empty";assert.equal((await collectPingForProbeIds([probeId])).polled,1);sample=await latest();assert.equal(sample.status,"unknown");assert.equal(sample.loss,null);cases++
|
||||||
|
mode="loss";await collectPingForProbeIds([probeId]);sample=await latest();assert.equal(sample.status,"down");assert.equal(sample.loss,100);assert.equal(sample.rtt,null);cases++
|
||||||
|
mode="zero";await collectPingForProbeIds([probeId]);sample=await latest();assert.equal(sample.status,"up");assert.equal(sample.loss,0);assert.equal(sample.rtt,0);cases++
|
||||||
|
response=await app.inject({method:"GET",url:"/uptime/overview?range=1h"});dto=response.json().probes.find((p:{id:string})=>p.id===probeId);assert.equal(dto.status,"up");assert.equal(dto.loss,0);cases++
|
||||||
|
// Reclassify exact legacy scheduled failures through retained provenance, without guessing real loss.
|
||||||
|
const oldTime=new Date(Date.now()-120_000).toISOString()
|
||||||
|
await pool.query("INSERT INTO uptime_probe_samples(probe_id,sampled_at,status,loss_pct) VALUES($1,$2,'down',100)",[probeId,oldTime])
|
||||||
|
await pool.query("INSERT INTO scheduler_runs(id,job_key,started_at,finished_at,status,result_json) VALUES($1,'uptime_ping',$2,$2,'error',$3::jsonb)",[prefix+"legacy",oldTime,JSON.stringify({v:1,job:"uptime_ping",sampledAt:oldTime,probes:[{probeId,ok:false,error:"legacy API denial"}]})])
|
||||||
|
const old=(await readProbeSamplesSince(since)).find(r=>r.probeId===probeId&&Date.parse(r.sampledAt)===Date.parse(oldTime))!;assert.equal(old.measurementKnown,false);cases++
|
||||||
|
await pool.query("UPDATE servers SET enabled=false WHERE id=$1",[serverId]);const before=requests
|
||||||
|
for(const url of[`/uptime/sources/${serverId}/interfaces`,`/uptime/speed-test/endpoints/${serverId}/interfaces`]){response=await app.inject({method:"GET",url});assert.equal(response.statusCode,409)}
|
||||||
|
assert.equal((await collectPingForProbeIds([probeId])).polled,0);assert.equal(requests,before);cases++
|
||||||
|
console.log(`ping measurement owned SQL: ${cases} cases; real HTTP client/401/empty/manual/loss/zero/API/provenance/disabled guards passed`)
|
||||||
|
}finally{
|
||||||
|
await app.close();await new Promise<void>(resolve=>http.close(()=>resolve()))
|
||||||
|
await pool.query("DELETE FROM scheduler_runs WHERE id LIKE $1",[prefix+"%"])
|
||||||
|
await pool.query("DELETE FROM uptime_probe_samples WHERE probe_id=$1",[probeId]);await pool.query("DELETE FROM uptime_probes WHERE id=$1",[probeId])
|
||||||
|
if(serverId!==undefined)await pool.query("DELETE FROM servers WHERE id=$1",[serverId])
|
||||||
|
await closePool()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { parsePing } from "./uptime-collector.js"
|
||||||
|
for (const rows of [[], [{}], [{status:"not enough permissions"}], [{sent:"0",received:"0","packet-loss":"0","avg-rtt":"0ms"}], [{sent:"4",received:"5","packet-loss":"0"}], [{sent:"4bad",received:"4","packet-loss":"0"}]]) {
|
||||||
|
const result=parsePing(rows)
|
||||||
|
assert.equal(result.measured,false);assert.equal(result.avgRtt,null)
|
||||||
|
}
|
||||||
|
const down=parsePing([{sent:"4",received:"0","packet-loss":"100","avg-rtt":"0ms"}])
|
||||||
|
assert.equal(down.measured,true);assert.equal(down.status,"down");assert.equal(down.loss,100);assert.equal(down.avgRtt,null)
|
||||||
|
const up=parsePing([{sent:"4",received:"4","packet-loss":"0","avg-rtt":"0ms"}])
|
||||||
|
assert.equal(up.measured,true);assert.equal(up.status,"up");assert.equal(up.loss,0);assert.equal(up.avgRtt,0)
|
||||||
|
const partial=parsePing([{time:"500us"},{status:"timeout"}])
|
||||||
|
assert.equal(partial.measured,true);assert.equal(partial.loss,50)
|
||||||
|
console.log("ping measurement: empty/unsupported is not a measurement; zero and real 100 percent loss preserved")
|
||||||
@@ -2,7 +2,7 @@ import { replacePingProbes } from "./ping-probe-replacement.js"
|
|||||||
import type { PingProbeWrite } from "@mmapp/contracts"
|
import type { PingProbeWrite } from "@mmapp/contracts"
|
||||||
import { and, asc, eq, gte, inArray, max, sql } from "drizzle-orm"
|
import { and, asc, eq, gte, inArray, max, sql } from "drizzle-orm"
|
||||||
import { listEnabledMonitoringServers } from "./monitoring-server-catalog.js"
|
import { listEnabledMonitoringServers } from "./monitoring-server-catalog.js"
|
||||||
import { db } from "../db/index.js"
|
import { db, dbAll } from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
uptimeProbeSamples,
|
uptimeProbeSamples,
|
||||||
uptimeProbes,
|
uptimeProbes,
|
||||||
@@ -76,7 +76,7 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent?
|
|||||||
.filter((v): v is number => v != null && Number.isFinite(v))
|
.filter((v): v is number => v != null && Number.isFinite(v))
|
||||||
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
|
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
|
||||||
const avgFromSummary = parsePingTimeMs(sum?.time) ?? parsePingTimeMs(sum?.["avg-rtt"])
|
const avgFromSummary = parsePingTimeMs(sum?.time) ?? parsePingTimeMs(sum?.["avg-rtt"])
|
||||||
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
|
let avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
|
||||||
const sent = Number.parseInt(String(sum?.sent ?? ""), 10)
|
const sent = Number.parseInt(String(sum?.sent ?? ""), 10)
|
||||||
const received = Number.parseInt(String(sum?.received ?? ""), 10)
|
const received = Number.parseInt(String(sum?.received ?? ""), 10)
|
||||||
const sentFallback = okReplies.length + timedOutReplies.length
|
const sentFallback = okReplies.length + timedOutReplies.length
|
||||||
@@ -86,13 +86,17 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent?
|
|||||||
const lossRaw = String(sum?.["packet-loss"] ?? "")
|
const lossRaw = String(sum?.["packet-loss"] ?? "")
|
||||||
const parsedLoss = Number.parseInt(lossRaw.replace("%", ""), 10)
|
const parsedLoss = Number.parseInt(lossRaw.replace("%", ""), 10)
|
||||||
const lossSource = Number.isFinite(parsedLoss) ? parsedLoss : computedLoss
|
const lossSource = Number.isFinite(parsedLoss) ? parsedLoss : computedLoss
|
||||||
|
const validCounts = (sum?.sent === undefined || (/^\d+$/.test(String(sum.sent).trim()) && Number.isSafeInteger(sent) && sent > 0))
|
||||||
|
&& (sum?.received === undefined || (/^\d+$/.test(String(sum.received).trim()) && Number.isSafeInteger(received) && received >= 0 && (sum?.sent === undefined || received <= sent)))
|
||||||
|
const measured = validCounts && typeof lossSource === "number" && Number.isFinite(lossSource) && lossSource >= 0 && lossSource <= 100
|
||||||
const loss =
|
const loss =
|
||||||
typeof lossSource === "number" && Number.isFinite(lossSource)
|
typeof lossSource === "number" && Number.isFinite(lossSource)
|
||||||
? Math.max(0, Math.min(100, lossSource))
|
? Math.max(0, Math.min(100, lossSource))
|
||||||
: 100
|
: 100
|
||||||
const status: "up" | "warn" | "down" =
|
const status: "up" | "warn" | "down" =
|
||||||
loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > PING_PROBE_WARN_RTT_MS ? "warn" : "up")
|
loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > PING_PROBE_WARN_RTT_MS ? "warn" : "up")
|
||||||
return { avgRtt, loss, status }
|
if (loss === 100 || !measured) avgRtt = null
|
||||||
|
return { avgRtt, loss, status, measured }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disabled excludes every server type from resource and ping collection. */
|
/** Disabled excludes every server type from resource and ping collection. */
|
||||||
@@ -269,6 +273,7 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
|
|||||||
const client = MikrotikClient.fromServer(src)
|
const client = MikrotikClient.fromServer(src)
|
||||||
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
|
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
|
||||||
const parsed = parsePing(results)
|
const parsed = parsePing(results)
|
||||||
|
if (!parsed.measured) throw new Error("RouterOS не вернул подтверждённые результаты Ping")
|
||||||
await db.insert(uptimeProbeSamples).values({
|
await db.insert(uptimeProbeSamples).values({
|
||||||
probeId: p.id,
|
probeId: p.id,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
@@ -295,6 +300,8 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
|
|||||||
rttMs: null,
|
rttMs: null,
|
||||||
lossPct: 100,
|
lossPct: 100,
|
||||||
status: "down",
|
status: "down",
|
||||||
|
measurementKnown: false,
|
||||||
|
observationError: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
snapshot.probes.push({
|
snapshot.probes.push({
|
||||||
probeId: p.id,
|
probeId: p.id,
|
||||||
@@ -305,8 +312,8 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
|
|||||||
srcInterface: p.srcInterface || "",
|
srcInterface: p.srcInterface || "",
|
||||||
ok: false,
|
ok: false,
|
||||||
rttMs: null,
|
rttMs: null,
|
||||||
lossPct: 100,
|
lossPct: null,
|
||||||
status: "down",
|
status: "unknown",
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -367,6 +374,7 @@ export async function collectPingForProbeIds(probeIds: string[]): Promise<{ poll
|
|||||||
const client = MikrotikClient.fromServer(src)
|
const client = MikrotikClient.fromServer(src)
|
||||||
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
|
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
|
||||||
const parsed = parsePing(results)
|
const parsed = parsePing(results)
|
||||||
|
if (!parsed.measured) throw new Error("RouterOS не вернул подтверждённые результаты Ping")
|
||||||
await db.insert(uptimeProbeSamples).values({
|
await db.insert(uptimeProbeSamples).values({
|
||||||
probeId: p.id,
|
probeId: p.id,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
@@ -374,13 +382,15 @@ export async function collectPingForProbeIds(probeIds: string[]): Promise<{ poll
|
|||||||
lossPct: parsed.loss,
|
lossPct: parsed.loss,
|
||||||
status: parsed.status,
|
status: parsed.status,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
await db.insert(uptimeProbeSamples).values({
|
await db.insert(uptimeProbeSamples).values({
|
||||||
probeId: p.id,
|
probeId: p.id,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
rttMs: null,
|
rttMs: null,
|
||||||
lossPct: 100,
|
lossPct: 100,
|
||||||
status: "down",
|
status: "down",
|
||||||
|
measurementKnown: false,
|
||||||
|
observationError: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
polled += 1
|
polled += 1
|
||||||
@@ -482,9 +492,26 @@ export async function replaceProbes(rows: PingProbeWrite[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function readProbeSamplesSince(sinceIso: string) {
|
export async function readProbeSamplesSince(sinceIso: string) {
|
||||||
return await db.select().from(uptimeProbeSamples)
|
const rows = await db.select().from(uptimeProbeSamples)
|
||||||
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
|
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
|
||||||
.orderBy(asc(uptimeProbeSamples.sampledAt))
|
.orderBy(asc(uptimeProbeSamples.sampledAt))
|
||||||
|
if (!rows.length) return rows
|
||||||
|
// Old scheduled failures can be classified only when their exact snapshot provenance still exists.
|
||||||
|
const failures = await dbAll<{probeId: string | null; sampledAt: string | null; error: string | null}>(`
|
||||||
|
SELECT probe->>'probeId' AS "probeId", result_json->>'sampledAt' AS "sampledAt", probe->>'error' AS error
|
||||||
|
FROM scheduler_runs CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
CASE WHEN jsonb_typeof(result_json->'probes')='array' THEN result_json->'probes' ELSE '[]'::jsonb END) probe
|
||||||
|
WHERE job_key='uptime_ping' AND status IN ('ok','error') AND finished_at >= ?
|
||||||
|
AND result_json->'v'='1'::jsonb AND result_json->>'job'='uptime_ping' AND probe->'ok'='false'::jsonb`, [sinceIso])
|
||||||
|
const bySample = new Map<string, string>()
|
||||||
|
for (const failure of failures) {
|
||||||
|
if (!failure.probeId || !failure.sampledAt || !Number.isFinite(Date.parse(failure.sampledAt))) continue
|
||||||
|
bySample.set(`${failure.probeId}\0${Date.parse(failure.sampledAt)}`, failure.error ?? "Результат измерения не подтверждён")
|
||||||
|
}
|
||||||
|
return rows.map(row => {
|
||||||
|
const error = bySample.get(`${row.probeId}\0${Date.parse(row.sampledAt)}`)
|
||||||
|
return error === undefined ? row : {...row,measurementKnown:false,observationError:error}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readResourceSamplesSince(sinceIso: string, serverId: number) {
|
export async function readResourceSamplesSince(sinceIso: string, serverId: number) {
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ import assert from "node:assert/strict"
|
|||||||
import {buildUptimeProbeObservations} from "./uptime-probe-observations.js"
|
import {buildUptimeProbeObservations} from "./uptime-probe-observations.js"
|
||||||
assert.deepEqual(buildUptimeProbeObservations([]),{samples:[]})
|
assert.deepEqual(buildUptimeProbeObservations([]),{samples:[]})
|
||||||
const good={sampledAt:"2026-10-03T00:00:00Z",rttMs:0,lossPct:0,status:"up" as const},failed={sampledAt:"2026-10-03T00:01:00Z",rttMs:null,lossPct:100,status:"down" as const}
|
const good={sampledAt:"2026-10-03T00:00:00Z",rttMs:0,lossPct:0,status:"up" as const},failed={sampledAt:"2026-10-03T00:01:00Z",rttMs:null,lossPct:100,status:"down" as const}
|
||||||
assert.deepEqual(buildUptimeProbeObservations([failed,good]),{samples:[{sampledAt:good.sampledAt,rtt:0,loss:0,status:"up"},{sampledAt:failed.sampledAt,rtt:null,loss:100,status:"down"}]});assert.equal(good.rttMs,0)
|
assert.deepEqual(buildUptimeProbeObservations([failed,good]),{samples:[{sampledAt:new Date(good.sampledAt).toISOString(),rtt:0,loss:0,status:"up"},{sampledAt:new Date(failed.sampledAt).toISOString(),rtt:null,loss:100,status:"down"}]});assert.equal(good.rttMs,0)
|
||||||
|
assert.deepEqual(buildUptimeProbeObservations([{...failed,measurementKnown:false,observationError:"API denied"}]).samples,[{sampledAt:new Date(failed.sampledAt).toISOString(),rtt:null,loss:null,status:"unknown",error:"API denied"}])
|
||||||
console.log("uptime probe observations tests ok")
|
console.log("uptime probe observations tests ok")
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {UptimeProbeSampleRow} from "../db/schema.js"
|
import type {UptimeProbeSampleRow} from "../db/schema.js"
|
||||||
type Sample=Pick<UptimeProbeSampleRow,"sampledAt"|"rttMs"|"lossPct"|"status">
|
type Sample=Pick<UptimeProbeSampleRow,"sampledAt"|"rttMs"|"lossPct"|"status">&Partial<Pick<UptimeProbeSampleRow,"measurementKnown"|"observationError">>
|
||||||
/** Additive metadata; no samples means no proof of loss or failure. */
|
/** Additive metadata; no samples means no proof of loss or failure. */
|
||||||
export function buildUptimeProbeObservations(rows:readonly Sample[]){return {samples:rows.map(row=>({sampledAt:row.sampledAt,rtt:row.rttMs,loss:row.lossPct,status:row.status})).sort((a,b)=>Date.parse(a.sampledAt)-Date.parse(b.sampledAt))}}
|
export function buildUptimeProbeObservations(rows:readonly Sample[]){return {samples:rows.map(row=>row.measurementKnown===false
|
||||||
|
? {sampledAt:new Date(row.sampledAt).toISOString(),rtt:null,loss:null,status:"unknown" as const,error:row.observationError??"Результат измерения не подтверждён"}
|
||||||
|
: {sampledAt:new Date(row.sampledAt).toISOString(),rtt:row.rttMs,loss:row.lossPct,status:row.status}).sort((a,b)=>Date.parse(a.sampledAt)-Date.parse(b.sampledAt))}}
|
||||||
@@ -128,6 +128,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams)
|
|||||||
try {
|
try {
|
||||||
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
|
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
|
||||||
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
|
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
|
||||||
|
if (!pr.measured) throw new Error("RouterOS не вернул подтверждённые результаты контрольного Ping")
|
||||||
afterBtPing = { rttMs: pr.avgRtt, lossPct: pr.loss, error: null }
|
afterBtPing = { rttMs: pr.avgRtt, lossPct: pr.loss, error: null }
|
||||||
} catch (pe) {
|
} catch (pe) {
|
||||||
afterBtPing = {
|
afterBtPing = {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export interface PingProbeSnapshot {
|
|||||||
srcInterface: string
|
srcInterface: string
|
||||||
ok: boolean
|
ok: boolean
|
||||||
rttMs: number | null
|
rttMs: number | null
|
||||||
lossPct: number
|
lossPct: number | null
|
||||||
status: "up" | "warn" | "down"
|
status: "up" | "warn" | "down" | "unknown"
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ function DashboardActiveProbesDataGrid({
|
|||||||
header: "Статус",
|
header: "Статус",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<StatusBadge
|
<span title={p.observationError ?? undefined}><StatusBadge
|
||||||
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
/>
|
/></span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UX/UI: протокол внешней приёмки 12.5
|
# UX/UI: протокол внешней приёмки 12.5
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
|
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
|
||||||
|
|
||||||
@@ -696,7 +696,7 @@ flowchart LR
|
|||||||
|
|
||||||
## 12.5.77 — основной блок IPsec (2026-10-09)
|
## 12.5.77 — основной блок IPsec (2026-10-09)
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||||
|
|
||||||
@@ -1948,3 +1948,28 @@ Source/build/unit/ESLint прошли. Локальные alert-engine и12 ск
|
|||||||
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
||||||
|
|
||||||
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
||||||
|
|
||||||
|
## 12.5.173 — ошибка Ping не подменяет измеренные потери (2026-10-12)
|
||||||
|
|
||||||
|
Завершён целый раздел producer→SQL→API→UI. parsePing теперь возвращает measured: empty/unsupported replies, zero sent и противоречивые/malformed counts не подтверждают процент потерь. Подтверждённые received0/sent4 остаются100%/down с RTTnull; успешный настоящий RTT0/loss0 сохранён. Scheduled и manual collectors различают успешное измерение и ошибку REST/недостаточный ответ; after-bandwidth Ping при неизвестном результате возвращает null metrics/error, а не выдуманный outage. Alert decoder принимает только ok=true и известное состояние; retained legacy ok=false/down/100 не срабатывает как измеренная потеря. Malformed due-probe rows без ID — barrier, не разрешение восстановить старое измерение.
|
||||||
|
|
||||||
|
Миграция0010 добавляет uptime_probe_samples.measurement_known boolean defaulttrue и observation_error text; CHECK/legacy storage status и integer loss не ломаются. Raw100/down в failed rows — прежние storage sentinel с явным measurement_known=false, не публичное измерение. Snapshot/public observations/API loss становятсяnull/statusunknown, есть причина ошибки. Новые flags пишутся при scheduled и manual error/empty response. Старые scheduled failure samples переклассифицируются readonly по точному probeId+sampledAt retained v1 JSON provenance; genuine100% без failure flag не меняется. Старые manual/retention-lost failures без provenance надёжно восстановить нельзя: они остаются legacy ambiguity, полного historical cleanup не заявляем. Migration additive, прошлые данные не удаляет; source JSON internal v1 теперь допускает nullable loss/unknown. Для rollback старый код не интерпретирует measurement_known, поэтому совместимость хранения не означает корректное чтение новых unknown attempts старой версией.
|
||||||
|
|
||||||
|
Overview выдаёт unknown/null без fallback100/down и без padded historical0. Observations timestamps канонизированы; parser проверяет согласованность unknown/null. /uptime использует тот же observed decoder, что dashboard: «Нет данных», — для RTT/loss, причина execution error, отдельный filter/URLstatus=unknown. Новая draft probe тоже не объявляется заранее up/0. Мини-Sparkline сохраняет gaps; expanded RTT chart больше не превращаетnull в0 и не дублирует одиночную точку для выдуманной линии. Dashboard status tooltip получает причину. Source/speed-interface read endpoints теперь409 до RouterOS при disabled server; ошибочная подпись «для ping всё равно можно выбрать выключенный» исправлена. Это соответствует прямому правилу пользователя не опрашивать выключенные устройства любых типов. UI/ReUI primitives/theme не заменены.
|
||||||
|
|
||||||
|
| Состояние | Эталон | Результат |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ошибка REST/нет измерения | Существующие probe rows/StatusDot/Sparkline | [ПК1440](ux-ui/screens/ping-measurement-unknown-1440-2026-10-12.png), [телефон320](ux-ui/screens/ping-measurement-unknown-320-2026-10-12.png) — API-fixture, не production positive sample |
|
||||||
|
| Подтверждённые100% | Тот же public observed path | Down и100% сохранены отдельным positive control |
|
||||||
|
|
||||||
|
Unit parser/observations/frontend dashboard-probes/URL guards прошли; backend build, application-source TypeScript и ESLint прошли (в uptime-collector два прежних unused settings warnings, functional scope не расширен). Первый npm --prefix exec взял неверный cwd, повтор direct tsx корректен; новый тест сначала использовал неверное имя Zod package, исправлен на установленный @fastify/type-provider-zod. Local cancellation test получил sandbox EACCES, разрешённый сетевой профиль прошёл; local own SQL skipped без выделенной базы.85 unique E2E Uptime/Ping/Speed: initial83passed/2failed были новые ambiguous test selectors (имя есть в sidebar и строке); selector исправлен на конкретную причину ошибки, delta3passed9.5s. Existing83 scenarios повторно не запускались;2 screenshot captures passed8.5s отдельно. Это не85 clean single-run и не87 новых сценариев. Два перезаписанных тестами прежних speed images восстановлены SHA256;8 user dirty GRE/VXLAN images не тронуты. Новые снимки просмотрены, nullable состояние читаемо при320px.
|
||||||
|
|
||||||
|
Owned Node22/PG18.6 профили alert-engine+probes прошли42380ms:26 source/history SQL cases, GRE/BGP8/actual stability и8 новых Ping cases. Последние проходят через реальный MikrotikClient HTTP к loopback simulator, persisted flags, GET overview Fastify path:401,empty,manual,real loss/zero,API,legacy exact provenance и disabled no-outbound requests. Simulator не является физическим RouterOS/network failure. Финальные unit guards zero/inconsistent counts/malformed probe проверены перед scoped rerun; temporary DB/role/container удалены. Production credentials/JWT не копировались. Type/producer tests подключены к стандартному test:probes; default profile count не увеличен.
|
||||||
|
|
||||||
|
Source440 backend+518 frontend inputs/1020 emitted parity совпали SHA256. Both production builds passed, backend image включает новый drizzle0010. Staged deploy: frontend ux-ping173 сначала принял старый API ux-history172, затем backend ux-ping173/version1.0.0-test.ping173. Migration0010 и2 новых columns подтверждены readonly в main PG. Backend/PG healthy, frontend running/HTTP/UI, imageIds verified, running containers3. В.env менялись толькоimage tags; rollback tags сохранились отдельно без secret env копии. Cached прежний frontend следует перезагрузить: новые nullable/unknown DTO требуют совместимого reader.
|
||||||
|
|
||||||
|
Независимо выполнен настоящий native Ping4 packets194.226.121.150→104.165.244.7 иобратно через running compiled173: обе команды приняты, обе4/4,0%loss, parsed RTT53ms. Это короткое наблюдение, не WAN SLA/max throughput. Main DB read-only, native конфигурация/пароли не менялись;2 operational Ping commands, routerConfigWrites0/messages0. На production configured probes0:2live UI1440/320 подтверждают actual empty catalog/new filter/reader/overflow/pageerrors0; nonempty/error UI доказан fixtures, не выдан за main positive measurement. Collector row/error flags проверены ownSQL, real parser отдельно native payloads.
|
||||||
|
|
||||||
|
Протоколы: [SQL/HTTP8+cleanup](ux-ui/acceptance/ping-measurement-sql-2026-10-12.json), [native4/4](ux-ui/acceptance/ping-measurement-native-2026-10-12.json), [source](ux-ui/acceptance/ping-measurement-source-2026-10-12.json), [compiled](ux-ui/acceptance/ping-measurement-compiled-2026-10-12.json), [interop](ux-ui/acceptance/ping-measurement-interop-2026-10-12.json), [runtime](ux-ui/acceptance/ping-measurement-runtime-2026-10-12.json), [live UI](ux-ui/acceptance/ping-measurement-deployed-2026-10-12.json).
|
||||||
|
|
||||||
|
Следующий174 — audit first-wave/invalid traffic rate confidence: ok request и numeric sum не всегда доказывают измеренный rate без previous wave. Не заявлять эту гипотезу как уже выполненное исправление. Source unconfirmed/missing target diagnostics, manual history provenance, unsupported/compound time formats, legacy ambiguous samples, scale/multi-instance/physical WAN/external integrations остаются отдельными условиями12.5. Полная acceptance открыта. Локальный коммит без push.
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
# План реализации UX/UI по пятичасовым окнам
|
# План реализации UX/UI по пятичасовым окнам
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-12
|
## Актуальная точка продолжения — 2026-10-12
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
|
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
|
||||||
|
|
||||||
@@ -371,6 +371,6 @@ flowchart LR
|
|||||||
|
|
||||||
## 12.5.77 — основной блок IPsec (2026-10-09)
|
## 12.5.77 — основной блок IPsec (2026-10-09)
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||||
+26
-1
@@ -1,6 +1,6 @@
|
|||||||
# UX/UI: журнал реализации
|
# UX/UI: журнал реализации
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||||
|
|
||||||
@@ -38,6 +38,31 @@
|
|||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
|
||||||
|
|
||||||
|
## 12.5.173 — ошибка Ping не подменяет измеренные потери (2026-10-12)
|
||||||
|
|
||||||
|
Завершён целый раздел producer→SQL→API→UI. parsePing теперь возвращает measured: empty/unsupported replies, zero sent и противоречивые/malformed counts не подтверждают процент потерь. Подтверждённые received0/sent4 остаются100%/down с RTTnull; успешный настоящий RTT0/loss0 сохранён. Scheduled и manual collectors различают успешное измерение и ошибку REST/недостаточный ответ; after-bandwidth Ping при неизвестном результате возвращает null metrics/error, а не выдуманный outage. Alert decoder принимает только ok=true и известное состояние; retained legacy ok=false/down/100 не срабатывает как измеренная потеря. Malformed due-probe rows без ID — barrier, не разрешение восстановить старое измерение.
|
||||||
|
|
||||||
|
Миграция0010 добавляет uptime_probe_samples.measurement_known boolean defaulttrue и observation_error text; CHECK/legacy storage status и integer loss не ломаются. Raw100/down в failed rows — прежние storage sentinel с явным measurement_known=false, не публичное измерение. Snapshot/public observations/API loss становятсяnull/statusunknown, есть причина ошибки. Новые flags пишутся при scheduled и manual error/empty response. Старые scheduled failure samples переклассифицируются readonly по точному probeId+sampledAt retained v1 JSON provenance; genuine100% без failure flag не меняется. Старые manual/retention-lost failures без provenance надёжно восстановить нельзя: они остаются legacy ambiguity, полного historical cleanup не заявляем. Migration additive, прошлые данные не удаляет; source JSON internal v1 теперь допускает nullable loss/unknown. Для rollback старый код не интерпретирует measurement_known, поэтому совместимость хранения не означает корректное чтение новых unknown attempts старой версией.
|
||||||
|
|
||||||
|
Overview выдаёт unknown/null без fallback100/down и без padded historical0. Observations timestamps канонизированы; parser проверяет согласованность unknown/null. /uptime использует тот же observed decoder, что dashboard: «Нет данных», — для RTT/loss, причина execution error, отдельный filter/URLstatus=unknown. Новая draft probe тоже не объявляется заранее up/0. Мини-Sparkline сохраняет gaps; expanded RTT chart больше не превращаетnull в0 и не дублирует одиночную точку для выдуманной линии. Dashboard status tooltip получает причину. Source/speed-interface read endpoints теперь409 до RouterOS при disabled server; ошибочная подпись «для ping всё равно можно выбрать выключенный» исправлена. Это соответствует прямому правилу пользователя не опрашивать выключенные устройства любых типов. UI/ReUI primitives/theme не заменены.
|
||||||
|
|
||||||
|
| Состояние | Эталон | Результат |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ошибка REST/нет измерения | Существующие probe rows/StatusDot/Sparkline | [ПК1440](ux-ui/screens/ping-measurement-unknown-1440-2026-10-12.png), [телефон320](ux-ui/screens/ping-measurement-unknown-320-2026-10-12.png) — API-fixture, не production positive sample |
|
||||||
|
| Подтверждённые100% | Тот же public observed path | Down и100% сохранены отдельным positive control |
|
||||||
|
|
||||||
|
Unit parser/observations/frontend dashboard-probes/URL guards прошли; backend build, application-source TypeScript и ESLint прошли (в uptime-collector два прежних unused settings warnings, functional scope не расширен). Первый npm --prefix exec взял неверный cwd, повтор direct tsx корректен; новый тест сначала использовал неверное имя Zod package, исправлен на установленный @fastify/type-provider-zod. Local cancellation test получил sandbox EACCES, разрешённый сетевой профиль прошёл; local own SQL skipped без выделенной базы.85 unique E2E Uptime/Ping/Speed: initial83passed/2failed были новые ambiguous test selectors (имя есть в sidebar и строке); selector исправлен на конкретную причину ошибки, delta3passed9.5s. Existing83 scenarios повторно не запускались;2 screenshot captures passed8.5s отдельно. Это не85 clean single-run и не87 новых сценариев. Два перезаписанных тестами прежних speed images восстановлены SHA256;8 user dirty GRE/VXLAN images не тронуты. Новые снимки просмотрены, nullable состояние читаемо при320px.
|
||||||
|
|
||||||
|
Owned Node22/PG18.6 профили alert-engine+probes прошли42380ms:26 source/history SQL cases, GRE/BGP8/actual stability и8 новых Ping cases. Последние проходят через реальный MikrotikClient HTTP к loopback simulator, persisted flags, GET overview Fastify path:401,empty,manual,real loss/zero,API,legacy exact provenance и disabled no-outbound requests. Simulator не является физическим RouterOS/network failure. Финальные unit guards zero/inconsistent counts/malformed probe проверены перед scoped rerun; temporary DB/role/container удалены. Production credentials/JWT не копировались. Type/producer tests подключены к стандартному test:probes; default profile count не увеличен.
|
||||||
|
|
||||||
|
Source440 backend+518 frontend inputs/1020 emitted parity совпали SHA256. Both production builds passed, backend image включает новый drizzle0010. Staged deploy: frontend ux-ping173 сначала принял старый API ux-history172, затем backend ux-ping173/version1.0.0-test.ping173. Migration0010 и2 новых columns подтверждены readonly в main PG. Backend/PG healthy, frontend running/HTTP/UI, imageIds verified, running containers3. В.env менялись толькоimage tags; rollback tags сохранились отдельно без secret env копии. Cached прежний frontend следует перезагрузить: новые nullable/unknown DTO требуют совместимого reader.
|
||||||
|
|
||||||
|
Независимо выполнен настоящий native Ping4 packets194.226.121.150→104.165.244.7 иобратно через running compiled173: обе команды приняты, обе4/4,0%loss, parsed RTT53ms. Это короткое наблюдение, не WAN SLA/max throughput. Main DB read-only, native конфигурация/пароли не менялись;2 operational Ping commands, routerConfigWrites0/messages0. На production configured probes0:2live UI1440/320 подтверждают actual empty catalog/new filter/reader/overflow/pageerrors0; nonempty/error UI доказан fixtures, не выдан за main positive measurement. Collector row/error flags проверены ownSQL, real parser отдельно native payloads.
|
||||||
|
|
||||||
|
Протоколы: [SQL/HTTP8+cleanup](ux-ui/acceptance/ping-measurement-sql-2026-10-12.json), [native4/4](ux-ui/acceptance/ping-measurement-native-2026-10-12.json), [source](ux-ui/acceptance/ping-measurement-source-2026-10-12.json), [compiled](ux-ui/acceptance/ping-measurement-compiled-2026-10-12.json), [interop](ux-ui/acceptance/ping-measurement-interop-2026-10-12.json), [runtime](ux-ui/acceptance/ping-measurement-runtime-2026-10-12.json), [live UI](ux-ui/acceptance/ping-measurement-deployed-2026-10-12.json).
|
||||||
|
|
||||||
|
Следующий174 — audit first-wave/invalid traffic rate confidence: ok request и numeric sum не всегда доказывают измеренный rate без previous wave. Не заявлять эту гипотезу как уже выполненное исправление. Source unconfirmed/missing target diagnostics, manual history provenance, unsupported/compound time formats, legacy ambiguous samples, scale/multi-instance/physical WAN/external integrations остаются отдельными условиями12.5. Полная acceptance открыта. Локальный коммит без push.
|
||||||
|
|
||||||
## 12.5.172 — подтверждённые текущие проблемы в журнале мониторинга (2026-10-12)
|
## 12.5.172 — подтверждённые текущие проблемы в журнале мониторинга (2026-10-12)
|
||||||
|
|
||||||
Внешнее воспроизведение выполнено на compiled ux-targets171 с own PostgreSQL/ограниченной ролью. Прежний overview принимал положительный raw sample и корректно выдавал timestamp string: гипотеза Date/schema failure НЕ подтвердилась. Первая baseline попытка ожидала ошибку и завершилась assertion failure; её результат сохранён, own fixture/DB/role очищены. Уточнённый baseline прошёл: один и тот же live offline оставался после disabled server, disabled resources source и замены sampledAt на часовой давности. Это положительное независимое подтверждение реального disabled/freshness дефекта прежней ветки, не вывод только из кода.
|
Внешнее воспроизведение выполнено на compiled ux-targets171 с own PostgreSQL/ограниченной ролью. Прежний overview принимал положительный raw sample и корректно выдавал timestamp string: гипотеза Date/schema failure НЕ подтвердилась. Первая baseline попытка ожидала ошибку и завершилась assertion failure; её результат сохранён, own fixture/DB/role очищены. Уточнённый baseline прошёл: один и тот же live offline оставался после disabled server, disabled resources source и замены sampledAt на часовой давности. Это положительное независимое подтверждение реального disabled/freshness дефекта прежней ветки, не вывод только из кода.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Оставшаяся внешняя приёмка — 2026-10-12
|
# Оставшаяся внешняя приёмка — 2026-10-12
|
||||||
|
|
||||||
Это актуальная матрица после 12.5.172. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
|
Это актуальная матрица после 12.5.173. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
|
||||||
|
|
||||||
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-history172`, frontend `ux-targets171`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
|
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-ping173`, frontend `ux-ping173`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
|
||||||
|
|
||||||
| Раздел | Подтверждено | Осталось / условие завершения |
|
| Раздел | Подтверждено | Осталось / условие завершения |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята |
|
| Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята |
|
||||||
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
|
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
|
||||||
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
|
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
|
||||||
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping execution error vs measured loss173 pending; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию |
|
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic first-wave confidence174 audit pending; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию |
|
||||||
|
|
||||||
Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
|
Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
|
||||||
|
|
||||||
@@ -863,3 +863,28 @@ Source/build/unit/ESLint прошли. Локальные alert-engine и12 ск
|
|||||||
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
||||||
|
|
||||||
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
||||||
|
|
||||||
|
## 12.5.173 — ошибка Ping не подменяет измеренные потери (2026-10-12)
|
||||||
|
|
||||||
|
Завершён целый раздел producer→SQL→API→UI. parsePing теперь возвращает measured: empty/unsupported replies, zero sent и противоречивые/malformed counts не подтверждают процент потерь. Подтверждённые received0/sent4 остаются100%/down с RTTnull; успешный настоящий RTT0/loss0 сохранён. Scheduled и manual collectors различают успешное измерение и ошибку REST/недостаточный ответ; after-bandwidth Ping при неизвестном результате возвращает null metrics/error, а не выдуманный outage. Alert decoder принимает только ok=true и известное состояние; retained legacy ok=false/down/100 не срабатывает как измеренная потеря. Malformed due-probe rows без ID — barrier, не разрешение восстановить старое измерение.
|
||||||
|
|
||||||
|
Миграция0010 добавляет uptime_probe_samples.measurement_known boolean defaulttrue и observation_error text; CHECK/legacy storage status и integer loss не ломаются. Raw100/down в failed rows — прежние storage sentinel с явным measurement_known=false, не публичное измерение. Snapshot/public observations/API loss становятсяnull/statusunknown, есть причина ошибки. Новые flags пишутся при scheduled и manual error/empty response. Старые scheduled failure samples переклассифицируются readonly по точному probeId+sampledAt retained v1 JSON provenance; genuine100% без failure flag не меняется. Старые manual/retention-lost failures без provenance надёжно восстановить нельзя: они остаются legacy ambiguity, полного historical cleanup не заявляем. Migration additive, прошлые данные не удаляет; source JSON internal v1 теперь допускает nullable loss/unknown. Для rollback старый код не интерпретирует measurement_known, поэтому совместимость хранения не означает корректное чтение новых unknown attempts старой версией.
|
||||||
|
|
||||||
|
Overview выдаёт unknown/null без fallback100/down и без padded historical0. Observations timestamps канонизированы; parser проверяет согласованность unknown/null. /uptime использует тот же observed decoder, что dashboard: «Нет данных», — для RTT/loss, причина execution error, отдельный filter/URLstatus=unknown. Новая draft probe тоже не объявляется заранее up/0. Мини-Sparkline сохраняет gaps; expanded RTT chart больше не превращаетnull в0 и не дублирует одиночную точку для выдуманной линии. Dashboard status tooltip получает причину. Source/speed-interface read endpoints теперь409 до RouterOS при disabled server; ошибочная подпись «для ping всё равно можно выбрать выключенный» исправлена. Это соответствует прямому правилу пользователя не опрашивать выключенные устройства любых типов. UI/ReUI primitives/theme не заменены.
|
||||||
|
|
||||||
|
| Состояние | Эталон | Результат |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ошибка REST/нет измерения | Существующие probe rows/StatusDot/Sparkline | [ПК1440](ux-ui/screens/ping-measurement-unknown-1440-2026-10-12.png), [телефон320](ux-ui/screens/ping-measurement-unknown-320-2026-10-12.png) — API-fixture, не production positive sample |
|
||||||
|
| Подтверждённые100% | Тот же public observed path | Down и100% сохранены отдельным positive control |
|
||||||
|
|
||||||
|
Unit parser/observations/frontend dashboard-probes/URL guards прошли; backend build, application-source TypeScript и ESLint прошли (в uptime-collector два прежних unused settings warnings, functional scope не расширен). Первый npm --prefix exec взял неверный cwd, повтор direct tsx корректен; новый тест сначала использовал неверное имя Zod package, исправлен на установленный @fastify/type-provider-zod. Local cancellation test получил sandbox EACCES, разрешённый сетевой профиль прошёл; local own SQL skipped без выделенной базы.85 unique E2E Uptime/Ping/Speed: initial83passed/2failed были новые ambiguous test selectors (имя есть в sidebar и строке); selector исправлен на конкретную причину ошибки, delta3passed9.5s. Existing83 scenarios повторно не запускались;2 screenshot captures passed8.5s отдельно. Это не85 clean single-run и не87 новых сценариев. Два перезаписанных тестами прежних speed images восстановлены SHA256;8 user dirty GRE/VXLAN images не тронуты. Новые снимки просмотрены, nullable состояние читаемо при320px.
|
||||||
|
|
||||||
|
Owned Node22/PG18.6 профили alert-engine+probes прошли42380ms:26 source/history SQL cases, GRE/BGP8/actual stability и8 новых Ping cases. Последние проходят через реальный MikrotikClient HTTP к loopback simulator, persisted flags, GET overview Fastify path:401,empty,manual,real loss/zero,API,legacy exact provenance и disabled no-outbound requests. Simulator не является физическим RouterOS/network failure. Финальные unit guards zero/inconsistent counts/malformed probe проверены перед scoped rerun; temporary DB/role/container удалены. Production credentials/JWT не копировались. Type/producer tests подключены к стандартному test:probes; default profile count не увеличен.
|
||||||
|
|
||||||
|
Source440 backend+518 frontend inputs/1020 emitted parity совпали SHA256. Both production builds passed, backend image включает новый drizzle0010. Staged deploy: frontend ux-ping173 сначала принял старый API ux-history172, затем backend ux-ping173/version1.0.0-test.ping173. Migration0010 и2 новых columns подтверждены readonly в main PG. Backend/PG healthy, frontend running/HTTP/UI, imageIds verified, running containers3. В.env менялись толькоimage tags; rollback tags сохранились отдельно без secret env копии. Cached прежний frontend следует перезагрузить: новые nullable/unknown DTO требуют совместимого reader.
|
||||||
|
|
||||||
|
Независимо выполнен настоящий native Ping4 packets194.226.121.150→104.165.244.7 иобратно через running compiled173: обе команды приняты, обе4/4,0%loss, parsed RTT53ms. Это короткое наблюдение, не WAN SLA/max throughput. Main DB read-only, native конфигурация/пароли не менялись;2 operational Ping commands, routerConfigWrites0/messages0. На production configured probes0:2live UI1440/320 подтверждают actual empty catalog/new filter/reader/overflow/pageerrors0; nonempty/error UI доказан fixtures, не выдан за main positive measurement. Collector row/error flags проверены ownSQL, real parser отдельно native payloads.
|
||||||
|
|
||||||
|
Протоколы: [SQL/HTTP8+cleanup](ux-ui/acceptance/ping-measurement-sql-2026-10-12.json), [native4/4](ux-ui/acceptance/ping-measurement-native-2026-10-12.json), [source](ux-ui/acceptance/ping-measurement-source-2026-10-12.json), [compiled](ux-ui/acceptance/ping-measurement-compiled-2026-10-12.json), [interop](ux-ui/acceptance/ping-measurement-interop-2026-10-12.json), [runtime](ux-ui/acceptance/ping-measurement-runtime-2026-10-12.json), [live UI](ux-ui/acceptance/ping-measurement-deployed-2026-10-12.json).
|
||||||
|
|
||||||
|
Следующий174 — audit first-wave/invalid traffic rate confidence: ok request и numeric sum не всегда доказывают измеренный rate без previous wave. Не заявлять эту гипотезу как уже выполненное исправление. Source unconfirmed/missing target diagnostics, manual history provenance, unsupported/compound time formats, legacy ambiguous samples, scale/multi-instance/physical WAN/external integrations остаются отдельными условиями12.5. Полная acceptance открыта. Локальный коммит без push.
|
||||||
+26
-1
@@ -1,6 +1,6 @@
|
|||||||
# Этап 12: итоговая приёмка UX/UI
|
# Этап 12: итоговая приёмка UX/UI
|
||||||
|
|
||||||
Актуальное продолжение 2026-10-12: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Актуальное продолжение 2026-10-12: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
|
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
|
||||||
|
|
||||||
@@ -3253,3 +3253,28 @@ Source/build/unit/ESLint прошли. Локальные alert-engine и12 ск
|
|||||||
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
||||||
|
|
||||||
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
||||||
|
|
||||||
|
## 12.5.173 — ошибка Ping не подменяет измеренные потери (2026-10-12)
|
||||||
|
|
||||||
|
Завершён целый раздел producer→SQL→API→UI. parsePing теперь возвращает measured: empty/unsupported replies, zero sent и противоречивые/malformed counts не подтверждают процент потерь. Подтверждённые received0/sent4 остаются100%/down с RTTnull; успешный настоящий RTT0/loss0 сохранён. Scheduled и manual collectors различают успешное измерение и ошибку REST/недостаточный ответ; after-bandwidth Ping при неизвестном результате возвращает null metrics/error, а не выдуманный outage. Alert decoder принимает только ok=true и известное состояние; retained legacy ok=false/down/100 не срабатывает как измеренная потеря. Malformed due-probe rows без ID — barrier, не разрешение восстановить старое измерение.
|
||||||
|
|
||||||
|
Миграция0010 добавляет uptime_probe_samples.measurement_known boolean defaulttrue и observation_error text; CHECK/legacy storage status и integer loss не ломаются. Raw100/down в failed rows — прежние storage sentinel с явным measurement_known=false, не публичное измерение. Snapshot/public observations/API loss становятсяnull/statusunknown, есть причина ошибки. Новые flags пишутся при scheduled и manual error/empty response. Старые scheduled failure samples переклассифицируются readonly по точному probeId+sampledAt retained v1 JSON provenance; genuine100% без failure flag не меняется. Старые manual/retention-lost failures без provenance надёжно восстановить нельзя: они остаются legacy ambiguity, полного historical cleanup не заявляем. Migration additive, прошлые данные не удаляет; source JSON internal v1 теперь допускает nullable loss/unknown. Для rollback старый код не интерпретирует measurement_known, поэтому совместимость хранения не означает корректное чтение новых unknown attempts старой версией.
|
||||||
|
|
||||||
|
Overview выдаёт unknown/null без fallback100/down и без padded historical0. Observations timestamps канонизированы; parser проверяет согласованность unknown/null. /uptime использует тот же observed decoder, что dashboard: «Нет данных», — для RTT/loss, причина execution error, отдельный filter/URLstatus=unknown. Новая draft probe тоже не объявляется заранее up/0. Мини-Sparkline сохраняет gaps; expanded RTT chart больше не превращаетnull в0 и не дублирует одиночную точку для выдуманной линии. Dashboard status tooltip получает причину. Source/speed-interface read endpoints теперь409 до RouterOS при disabled server; ошибочная подпись «для ping всё равно можно выбрать выключенный» исправлена. Это соответствует прямому правилу пользователя не опрашивать выключенные устройства любых типов. UI/ReUI primitives/theme не заменены.
|
||||||
|
|
||||||
|
| Состояние | Эталон | Результат |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ошибка REST/нет измерения | Существующие probe rows/StatusDot/Sparkline | [ПК1440](ux-ui/screens/ping-measurement-unknown-1440-2026-10-12.png), [телефон320](ux-ui/screens/ping-measurement-unknown-320-2026-10-12.png) — API-fixture, не production positive sample |
|
||||||
|
| Подтверждённые100% | Тот же public observed path | Down и100% сохранены отдельным positive control |
|
||||||
|
|
||||||
|
Unit parser/observations/frontend dashboard-probes/URL guards прошли; backend build, application-source TypeScript и ESLint прошли (в uptime-collector два прежних unused settings warnings, functional scope не расширен). Первый npm --prefix exec взял неверный cwd, повтор direct tsx корректен; новый тест сначала использовал неверное имя Zod package, исправлен на установленный @fastify/type-provider-zod. Local cancellation test получил sandbox EACCES, разрешённый сетевой профиль прошёл; local own SQL skipped без выделенной базы.85 unique E2E Uptime/Ping/Speed: initial83passed/2failed были новые ambiguous test selectors (имя есть в sidebar и строке); selector исправлен на конкретную причину ошибки, delta3passed9.5s. Existing83 scenarios повторно не запускались;2 screenshot captures passed8.5s отдельно. Это не85 clean single-run и не87 новых сценариев. Два перезаписанных тестами прежних speed images восстановлены SHA256;8 user dirty GRE/VXLAN images не тронуты. Новые снимки просмотрены, nullable состояние читаемо при320px.
|
||||||
|
|
||||||
|
Owned Node22/PG18.6 профили alert-engine+probes прошли42380ms:26 source/history SQL cases, GRE/BGP8/actual stability и8 новых Ping cases. Последние проходят через реальный MikrotikClient HTTP к loopback simulator, persisted flags, GET overview Fastify path:401,empty,manual,real loss/zero,API,legacy exact provenance и disabled no-outbound requests. Simulator не является физическим RouterOS/network failure. Финальные unit guards zero/inconsistent counts/malformed probe проверены перед scoped rerun; temporary DB/role/container удалены. Production credentials/JWT не копировались. Type/producer tests подключены к стандартному test:probes; default profile count не увеличен.
|
||||||
|
|
||||||
|
Source440 backend+518 frontend inputs/1020 emitted parity совпали SHA256. Both production builds passed, backend image включает новый drizzle0010. Staged deploy: frontend ux-ping173 сначала принял старый API ux-history172, затем backend ux-ping173/version1.0.0-test.ping173. Migration0010 и2 новых columns подтверждены readonly в main PG. Backend/PG healthy, frontend running/HTTP/UI, imageIds verified, running containers3. В.env менялись толькоimage tags; rollback tags сохранились отдельно без secret env копии. Cached прежний frontend следует перезагрузить: новые nullable/unknown DTO требуют совместимого reader.
|
||||||
|
|
||||||
|
Независимо выполнен настоящий native Ping4 packets194.226.121.150→104.165.244.7 иобратно через running compiled173: обе команды приняты, обе4/4,0%loss, parsed RTT53ms. Это короткое наблюдение, не WAN SLA/max throughput. Main DB read-only, native конфигурация/пароли не менялись;2 operational Ping commands, routerConfigWrites0/messages0. На production configured probes0:2live UI1440/320 подтверждают actual empty catalog/new filter/reader/overflow/pageerrors0; nonempty/error UI доказан fixtures, не выдан за main positive measurement. Collector row/error flags проверены ownSQL, real parser отдельно native payloads.
|
||||||
|
|
||||||
|
Протоколы: [SQL/HTTP8+cleanup](ux-ui/acceptance/ping-measurement-sql-2026-10-12.json), [native4/4](ux-ui/acceptance/ping-measurement-native-2026-10-12.json), [source](ux-ui/acceptance/ping-measurement-source-2026-10-12.json), [compiled](ux-ui/acceptance/ping-measurement-compiled-2026-10-12.json), [interop](ux-ui/acceptance/ping-measurement-interop-2026-10-12.json), [runtime](ux-ui/acceptance/ping-measurement-runtime-2026-10-12.json), [live UI](ux-ui/acceptance/ping-measurement-deployed-2026-10-12.json).
|
||||||
|
|
||||||
|
Следующий174 — audit first-wave/invalid traffic rate confidence: ok request и numeric sum не всегда доказывают измеренный rate без previous wave. Не заявлять эту гипотезу как уже выполненное исправление. Source unconfirmed/missing target diagnostics, manual history provenance, unsupported/compound time formats, legacy ambiguous samples, scale/multi-instance/physical WAN/external integrations остаются отдельными условиями12.5. Полная acceptance открыта. Локальный коммит без push.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Тестовый сервер UX/UI — 2026-10-10
|
# Тестовый сервер UX/UI — 2026-10-10
|
||||||
|
|
||||||
Сервер161.104.46.222: завершён12.5.172 — monitoring history использует общие confirmed collector facts внутри readonly transaction; disabled/stale/error/unknown не поднимают raw samples. Старый backend реально показывал disabled/hour-old sample; Date schema failure не воспроизведён. OwnSQL26+GRE8/stability/source437/compiled1020/build/4E2E/2live UI passed; own DB/roles removed. Backend ux-history172 healthy, frontend ux-targets171 прежний, оба CHR selected API200. Следующий173 — различить ошибку выполнения Ping и измеренные потери: producer catch пишет100%, guard пока принимает ok=false down;12.5 открыт.
|
Сервер161.104.46.222: завершён12.5.173 — Ping execution error/empty payload не считается100% loss: measurement_known/error provenance, nullable unknown API/UI, scheduled/manual/after-BT, malformed/zero/inconsistent count guards.85 unique E2E initial83+delta3, ownSQL26+Ping8+GRE8/stability, source440+518/compiled1020/build passed. Migration0010 applied; оба ux-ping173 healthy/running; real CHR Ping4/4 both/0%/53ms, config/password unchanged. Own DB/role removed. Следующий174 — audit first-wave/invalid traffic rate confidence; legacy unclassified manual samples и source unconfirmed diagnostics остаются;12.5 открыт.
|
||||||
|
|
||||||
| Параметр | Проверенное состояние |
|
| Параметр | Проверенное состояние |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
|
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
|
||||||
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
|
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
|
||||||
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
|
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
|
||||||
| Образы приложения | mmapp-test/backend:ux-history172 и mmapp-test/frontend:ux-targets171; запущены |
|
| Образы приложения | mmapp-test/backend:ux-ping173 и mmapp-test/frontend:ux-ping173; запущены |
|
||||||
|
|
||||||
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
|
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
|
||||||
|
|
||||||
@@ -1679,3 +1679,28 @@ Source/build/unit/ESLint прошли. Локальные alert-engine и12 ск
|
|||||||
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
Протоколы: [first baseline hypothesis](ux-ui/acceptance/monitoring-history-baseline-attempt-2026-10-12.json), [real baseline](ux-ui/acceptance/monitoring-history-baseline-2026-10-12.json), [SQL26/cleanup](ux-ui/acceptance/monitoring-history-sql-2026-10-12.json), [source437](ux-ui/acceptance/monitoring-history-source-2026-10-12.json), [compiled1020](ux-ui/acceptance/monitoring-history-compiled-2026-10-12.json), [runtime](ux-ui/acceptance/monitoring-history-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/monitoring-history-deployed-2026-10-12.json).
|
||||||
|
|
||||||
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
Следующий173 — source finding: collectPingProbesOnce/pingProbesNow catch превращает ошибку REST-команды в lossPct100/down; decoder пока разрешает ok=false/down/null/100. Это не независимое доказательство потери пакетов. Проверить и исправить подтверждение измерений/empty payload; не объявлять legacy raw UI stats и manual provenance уже исправленными. Также остаются unconfirmed/missing target diagnostics, stable-ID migration, scale/multi-instance/physical WAN и внешние Telegram/Portal/client OS.12.5 открыт. Локальный коммит без push.
|
||||||
|
|
||||||
|
## 12.5.173 — ошибка Ping не подменяет измеренные потери (2026-10-12)
|
||||||
|
|
||||||
|
Завершён целый раздел producer→SQL→API→UI. parsePing теперь возвращает measured: empty/unsupported replies, zero sent и противоречивые/malformed counts не подтверждают процент потерь. Подтверждённые received0/sent4 остаются100%/down с RTTnull; успешный настоящий RTT0/loss0 сохранён. Scheduled и manual collectors различают успешное измерение и ошибку REST/недостаточный ответ; after-bandwidth Ping при неизвестном результате возвращает null metrics/error, а не выдуманный outage. Alert decoder принимает только ok=true и известное состояние; retained legacy ok=false/down/100 не срабатывает как измеренная потеря. Malformed due-probe rows без ID — barrier, не разрешение восстановить старое измерение.
|
||||||
|
|
||||||
|
Миграция0010 добавляет uptime_probe_samples.measurement_known boolean defaulttrue и observation_error text; CHECK/legacy storage status и integer loss не ломаются. Raw100/down в failed rows — прежние storage sentinel с явным measurement_known=false, не публичное измерение. Snapshot/public observations/API loss становятсяnull/statusunknown, есть причина ошибки. Новые flags пишутся при scheduled и manual error/empty response. Старые scheduled failure samples переклассифицируются readonly по точному probeId+sampledAt retained v1 JSON provenance; genuine100% без failure flag не меняется. Старые manual/retention-lost failures без provenance надёжно восстановить нельзя: они остаются legacy ambiguity, полного historical cleanup не заявляем. Migration additive, прошлые данные не удаляет; source JSON internal v1 теперь допускает nullable loss/unknown. Для rollback старый код не интерпретирует measurement_known, поэтому совместимость хранения не означает корректное чтение новых unknown attempts старой версией.
|
||||||
|
|
||||||
|
Overview выдаёт unknown/null без fallback100/down и без padded historical0. Observations timestamps канонизированы; parser проверяет согласованность unknown/null. /uptime использует тот же observed decoder, что dashboard: «Нет данных», — для RTT/loss, причина execution error, отдельный filter/URLstatus=unknown. Новая draft probe тоже не объявляется заранее up/0. Мини-Sparkline сохраняет gaps; expanded RTT chart больше не превращаетnull в0 и не дублирует одиночную точку для выдуманной линии. Dashboard status tooltip получает причину. Source/speed-interface read endpoints теперь409 до RouterOS при disabled server; ошибочная подпись «для ping всё равно можно выбрать выключенный» исправлена. Это соответствует прямому правилу пользователя не опрашивать выключенные устройства любых типов. UI/ReUI primitives/theme не заменены.
|
||||||
|
|
||||||
|
| Состояние | Эталон | Результат |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ошибка REST/нет измерения | Существующие probe rows/StatusDot/Sparkline | [ПК1440](ux-ui/screens/ping-measurement-unknown-1440-2026-10-12.png), [телефон320](ux-ui/screens/ping-measurement-unknown-320-2026-10-12.png) — API-fixture, не production positive sample |
|
||||||
|
| Подтверждённые100% | Тот же public observed path | Down и100% сохранены отдельным positive control |
|
||||||
|
|
||||||
|
Unit parser/observations/frontend dashboard-probes/URL guards прошли; backend build, application-source TypeScript и ESLint прошли (в uptime-collector два прежних unused settings warnings, functional scope не расширен). Первый npm --prefix exec взял неверный cwd, повтор direct tsx корректен; новый тест сначала использовал неверное имя Zod package, исправлен на установленный @fastify/type-provider-zod. Local cancellation test получил sandbox EACCES, разрешённый сетевой профиль прошёл; local own SQL skipped без выделенной базы.85 unique E2E Uptime/Ping/Speed: initial83passed/2failed были новые ambiguous test selectors (имя есть в sidebar и строке); selector исправлен на конкретную причину ошибки, delta3passed9.5s. Existing83 scenarios повторно не запускались;2 screenshot captures passed8.5s отдельно. Это не85 clean single-run и не87 новых сценариев. Два перезаписанных тестами прежних speed images восстановлены SHA256;8 user dirty GRE/VXLAN images не тронуты. Новые снимки просмотрены, nullable состояние читаемо при320px.
|
||||||
|
|
||||||
|
Owned Node22/PG18.6 профили alert-engine+probes прошли42380ms:26 source/history SQL cases, GRE/BGP8/actual stability и8 новых Ping cases. Последние проходят через реальный MikrotikClient HTTP к loopback simulator, persisted flags, GET overview Fastify path:401,empty,manual,real loss/zero,API,legacy exact provenance и disabled no-outbound requests. Simulator не является физическим RouterOS/network failure. Финальные unit guards zero/inconsistent counts/malformed probe проверены перед scoped rerun; temporary DB/role/container удалены. Production credentials/JWT не копировались. Type/producer tests подключены к стандартному test:probes; default profile count не увеличен.
|
||||||
|
|
||||||
|
Source440 backend+518 frontend inputs/1020 emitted parity совпали SHA256. Both production builds passed, backend image включает новый drizzle0010. Staged deploy: frontend ux-ping173 сначала принял старый API ux-history172, затем backend ux-ping173/version1.0.0-test.ping173. Migration0010 и2 новых columns подтверждены readonly в main PG. Backend/PG healthy, frontend running/HTTP/UI, imageIds verified, running containers3. В.env менялись толькоimage tags; rollback tags сохранились отдельно без secret env копии. Cached прежний frontend следует перезагрузить: новые nullable/unknown DTO требуют совместимого reader.
|
||||||
|
|
||||||
|
Независимо выполнен настоящий native Ping4 packets194.226.121.150→104.165.244.7 иобратно через running compiled173: обе команды приняты, обе4/4,0%loss, parsed RTT53ms. Это короткое наблюдение, не WAN SLA/max throughput. Main DB read-only, native конфигурация/пароли не менялись;2 operational Ping commands, routerConfigWrites0/messages0. На production configured probes0:2live UI1440/320 подтверждают actual empty catalog/new filter/reader/overflow/pageerrors0; nonempty/error UI доказан fixtures, не выдан за main positive measurement. Collector row/error flags проверены ownSQL, real parser отдельно native payloads.
|
||||||
|
|
||||||
|
Протоколы: [SQL/HTTP8+cleanup](ux-ui/acceptance/ping-measurement-sql-2026-10-12.json), [native4/4](ux-ui/acceptance/ping-measurement-native-2026-10-12.json), [source](ux-ui/acceptance/ping-measurement-source-2026-10-12.json), [compiled](ux-ui/acceptance/ping-measurement-compiled-2026-10-12.json), [interop](ux-ui/acceptance/ping-measurement-interop-2026-10-12.json), [runtime](ux-ui/acceptance/ping-measurement-runtime-2026-10-12.json), [live UI](ux-ui/acceptance/ping-measurement-deployed-2026-10-12.json).
|
||||||
|
|
||||||
|
Следующий174 — audit first-wave/invalid traffic rate confidence: ok request и numeric sum не всегда доказывают измеренный rate без previous wave. Не заявлять эту гипотезу как уже выполненное исправление. Source unconfirmed/missing target diagnostics, manual history provenance, unsupported/compound time formats, legacy ambiguous samples, scale/multi-instance/physical WAN/external integrations остаются отдельными условиями12.5. Полная acceptance открыта. Локальный коммит без push.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"runtime": {
|
||||||
|
"backend": "ux-ping173",
|
||||||
|
"frontend": "ux-ping173"
|
||||||
|
},
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"width": 1440,
|
||||||
|
"overview200": true,
|
||||||
|
"unknownFilterPresent": true,
|
||||||
|
"pageErrors": 0,
|
||||||
|
"noOverflow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 320,
|
||||||
|
"overview200": true,
|
||||||
|
"unknownFilterPresent": true,
|
||||||
|
"pageErrors": 0,
|
||||||
|
"noOverflow": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bounds": [
|
||||||
|
"Actual empty configured probe catalog; positive/error UI proven by API-path fixtures and own HTTP/SQL",
|
||||||
|
"Real CHR parser checked separately by readonly native Ping, not by empty production catalog",
|
||||||
|
"No config/password/message writes during this read-only browser smoke"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"newFrontend": "ux-ping173",
|
||||||
|
"oldBackend": "ux-history172",
|
||||||
|
"readerAccepted": true,
|
||||||
|
"probeCount": 0,
|
||||||
|
"bound": "Actual empty configured catalog; nonempty old valid payloads separately covered by unit/UI fixtures"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"version": "1.0.0-test.ping173",
|
||||||
|
"readOnlyDatabase": true,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"sourceId": 1,
|
||||||
|
"target": "104.165.244.7",
|
||||||
|
"requestAccepted": true,
|
||||||
|
"measured": true,
|
||||||
|
"status": "up",
|
||||||
|
"rttMs": 53,
|
||||||
|
"lossPct": 0,
|
||||||
|
"raw": [
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms561us",
|
||||||
|
"host": "104.165.244.7",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "1",
|
||||||
|
"sent": "1",
|
||||||
|
"seq": "0",
|
||||||
|
"time": "53ms561us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms376us",
|
||||||
|
"host": "104.165.244.7",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "2",
|
||||||
|
"sent": "2",
|
||||||
|
"seq": "1",
|
||||||
|
"time": "53ms191us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms368us",
|
||||||
|
"host": "104.165.244.7",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "3",
|
||||||
|
"sent": "3",
|
||||||
|
"seq": "2",
|
||||||
|
"time": "53ms352us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms333us",
|
||||||
|
"host": "104.165.244.7",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "4",
|
||||||
|
"sent": "4",
|
||||||
|
"seq": "3",
|
||||||
|
"time": "53ms231us"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceId": 2,
|
||||||
|
"target": "194.226.121.150",
|
||||||
|
"requestAccepted": true,
|
||||||
|
"measured": true,
|
||||||
|
"status": "up",
|
||||||
|
"rttMs": 53,
|
||||||
|
"lossPct": 0,
|
||||||
|
"raw": [
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms593us",
|
||||||
|
"host": "194.226.121.150",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "1",
|
||||||
|
"sent": "1",
|
||||||
|
"seq": "0",
|
||||||
|
"time": "53ms593us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms373us",
|
||||||
|
"host": "194.226.121.150",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "2",
|
||||||
|
"sent": "2",
|
||||||
|
"seq": "1",
|
||||||
|
"time": "53ms153us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms349us",
|
||||||
|
"host": "194.226.121.150",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "3",
|
||||||
|
"sent": "3",
|
||||||
|
"seq": "2",
|
||||||
|
"time": "53ms301us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg-rtt": "53ms364us",
|
||||||
|
"host": "194.226.121.150",
|
||||||
|
"packet-loss": "0",
|
||||||
|
"received": "4",
|
||||||
|
"sent": "4",
|
||||||
|
"seq": "3",
|
||||||
|
"time": "53ms409us"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dbWrites": 0,
|
||||||
|
"routerConfigWrites": 0,
|
||||||
|
"messages": 0,
|
||||||
|
"migrationApplied": true,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"services": {
|
||||||
|
"backend": {
|
||||||
|
"image": "mmapp-test/backend:ux-ping173",
|
||||||
|
"imageId": "sha256:8fe4ab84759f4d09a2dc775f91fd4266e503225ec3b6be8466d937f926ac97da",
|
||||||
|
"matchedVerifiedImage": true,
|
||||||
|
"state": "running",
|
||||||
|
"health": "healthy"
|
||||||
|
},
|
||||||
|
"frontend": {
|
||||||
|
"image": "mmapp-test/frontend:ux-ping173",
|
||||||
|
"imageId": "sha256:b73d9cbd1e056839fe17b66f5f2b1324cd809fbc2699b4512fcd8040cc7501f3",
|
||||||
|
"matchedVerifiedImage": true,
|
||||||
|
"state": "running",
|
||||||
|
"health": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rollbackTags": {
|
||||||
|
"MMAPP_IMAGE_TAG": "ux-targets171",
|
||||||
|
"BACKEND_IMAGE_TAG": "ux-history172"
|
||||||
|
},
|
||||||
|
"secretEnvCopied": false,
|
||||||
|
"runningContainers": [
|
||||||
|
"mmapp-test-backend",
|
||||||
|
"mmapp-test-frontend",
|
||||||
|
"mmapp-test-postgres"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"matched": {
|
||||||
|
"backend": 440,
|
||||||
|
"frontend": 518
|
||||||
|
},
|
||||||
|
"different": [],
|
||||||
|
"missing": [],
|
||||||
|
"sourceBase": "ca23284486ab9cade3a5d59bb41c685d5b7c9dcc"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"block": "12.5.173",
|
||||||
|
"database": "mm_accept_166_1791801498307",
|
||||||
|
"scope": "ephemeral role and precreated owned database; no copied production credentials",
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"command": "npm --prefix backend run test:alert-engine",
|
||||||
|
"passed": true,
|
||||||
|
"durationMs": 42380,
|
||||||
|
"ownedHistorySqlRows": 8,
|
||||||
|
"ownedSourceSqlCases": 26,
|
||||||
|
"pingMeasurementSqlCases": 8,
|
||||||
|
"sqlConfirmStabilityPassed": true,
|
||||||
|
"postgresUnavailableSkip": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scopedRoleVerified": true,
|
||||||
|
"passed": true,
|
||||||
|
"databaseRemoved": true,
|
||||||
|
"roleRemoved": true,
|
||||||
|
"testContainerRemoved": true
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -19,3 +19,9 @@ test("ping observed zero and failure retain independent measurement values and s
|
|||||||
test("ping invalid time axis, duplicate or out of range samples cannot replace cache",()=>{
|
test("ping invalid time axis, duplicate or out of range samples cannot replace cache",()=>{
|
||||||
for(const raw of [payload([sampled,sampled]),payload([{...sampled,sampledAt:"invalid"}]),payload([{...sampled,sampledAt:"2026-10-02T00:00:00Z"}]),{...payload([sampled]),rangeEndAt:start},{...payload([sampled]),rangeStartAt:undefined},payload([{...sampled,loss:101}])])assert.throws(()=>parseDashboardProbes(raw))
|
for(const raw of [payload([sampled,sampled]),payload([{...sampled,sampledAt:"invalid"}]),payload([{...sampled,sampledAt:"2026-10-02T00:00:00Z"}]),{...payload([sampled]),rangeEndAt:start},{...payload([sampled]),rangeStartAt:undefined},payload([{...sampled,loss:101}])])assert.throws(()=>parseDashboardProbes(raw))
|
||||||
})
|
})
|
||||||
|
test("failed execution is an unknown observation, while measured 100 percent loss remains down",()=>{
|
||||||
|
const failed={...sampled,sampledAt:"2026-10-03T00:31:00Z",rtt:null,loss:null,status:"unknown",error:"API denied"}
|
||||||
|
const p=parseDashboardProbes(payload([sampled,failed])).probes[0]
|
||||||
|
assert.equal(p.status,"unknown");assert.equal(p.rtt,null);assert.equal(p.loss,null);assert.equal(p.observationError,"API denied");assert.equal(p.series[31],null)
|
||||||
|
assert.throws(()=>parseDashboardProbes(payload([{...failed,loss:100}])))
|
||||||
|
})
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import {z} from "zod"
|
import {z} from "zod"
|
||||||
import type {PingProbe} from "@/lib/data"
|
import type {PingProbe} from "@/lib/data"
|
||||||
const date=z.iso.datetime({offset:true})
|
const date=z.iso.datetime({offset:true})
|
||||||
const sample=z.object({sampledAt:date,rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100),status:z.enum(["up","warn","down"])})
|
const sample=z.object({sampledAt:date,rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),error:z.string().optional()}).refine(s=>s.status==="unknown"?s.rtt===null&&s.loss===null:s.loss!==null)
|
||||||
const probe=z.object({id:z.string().trim().min(1),srcServerId:z.string().trim().min(1),srcInterface:z.string().optional(),name:z.string(),target:z.string(),filter:z.string(),rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100),status:z.enum(["up","warn","down"]),series:z.array(z.number().finite().nonnegative()),enabled:z.boolean(),showOnDashboard:z.boolean(),observations:z.object({samples:z.array(sample)}).optional()})
|
const probe=z.object({id:z.string().trim().min(1),srcServerId:z.string().trim().min(1),srcInterface:z.string().optional(),name:z.string(),target:z.string(),filter:z.string(),rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),series:z.array(z.number().finite().nonnegative().nullable()),enabled:z.boolean(),showOnDashboard:z.boolean(),observations:z.object({samples:z.array(sample)}).optional()})
|
||||||
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length),rangeStartAt:date.optional(),rangeEndAt:date.optional()}).refine(raw=>{
|
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length),rangeStartAt:date.optional(),rangeEndAt:date.optional()}).refine(raw=>{
|
||||||
if(!raw.probes.some(p=>p.observations))return true
|
if(!raw.probes.some(p=>p.observations))return true
|
||||||
if(!raw.rangeStartAt||!raw.rangeEndAt)return false
|
if(!raw.rangeStartAt||!raw.rangeEndAt)return false
|
||||||
const start=Date.parse(raw.rangeStartAt),end=Date.parse(raw.rangeEndAt)
|
const start=Date.parse(raw.rangeStartAt),end=Date.parse(raw.rangeEndAt)
|
||||||
return end>start&&raw.probes.every(p=>{const samples=p.observations?.samples??[];return new Set(samples.map(s=>Date.parse(s.sampledAt))).size===samples.length&&samples.every((s,i)=>Date.parse(s.sampledAt)>=start&&Date.parse(s.sampledAt)<=end&&(i===0||Date.parse(s.sampledAt)>Date.parse(samples[i-1].sampledAt)))})
|
return end>start&&raw.probes.every(p=>{const samples=p.observations?.samples??[];return new Set(samples.map(s=>Date.parse(s.sampledAt))).size===samples.length&&samples.every((s,i)=>Date.parse(s.sampledAt)>=start&&Date.parse(s.sampledAt)<=end&&(i===0||Date.parse(s.sampledAt)>Date.parse(samples[i-1].sampledAt)))})
|
||||||
})
|
})
|
||||||
export type DashboardProbe=Omit<PingProbe,"status"|"loss"|"series">&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean}
|
export type DashboardProbe=Omit<PingProbe,"status"|"loss"|"series">&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean;observationError?:string|null}
|
||||||
function meanRtt(values:Array<number|null>){const mean=values.reduce<number>((sum,v)=>sum+(v??0),0)/values.length;if(!Number.isFinite(mean))throw Error("Средний RTT превышает допустимый диапазон");return Math.round(mean)}
|
function meanRtt(values:Array<number|null>){const mean=values.reduce<number>((sum,v)=>sum+(v??0),0)/values.length;if(!Number.isFinite(mean))throw Error("Средний RTT превышает допустимый диапазон");return Math.round(mean)}
|
||||||
export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampledAt:string|null}{
|
export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampledAt:string|null}{
|
||||||
const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб")
|
const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб")
|
||||||
const data=parsed.data,start=data.rangeStartAt?Date.parse(data.rangeStartAt):0,end=data.rangeEndAt?Date.parse(data.rangeEndAt):0
|
const data=parsed.data,start=data.rangeStartAt?Date.parse(data.rangeStartAt):0,end=data.rangeEndAt?Date.parse(data.rangeEndAt):0
|
||||||
const probes:DashboardProbe[]=data.probes.map(p=>{const samples=p.observations?.samples??[],last=samples.at(-1);const buckets:Array<Array<number|null>>=Array.from({length:60},()=>[])
|
const probes:DashboardProbe[]=data.probes.map(p=>{const samples=p.observations?.samples??[],last=samples.at(-1);const buckets:Array<Array<number|null>>=Array.from({length:60},()=>[])
|
||||||
for(const s of samples){const i=Math.min(59,Math.floor((Date.parse(s.sampledAt)-start)/(end-start)*60));buckets[i].push(s.rtt)}
|
for(const s of samples){const i=Math.min(59,Math.floor((Date.parse(s.sampledAt)-start)/(end-start)*60));buckets[i].push(s.rtt)}
|
||||||
return {...p,status:last?.status??"unknown",rtt:last?.rtt??null,loss:last?.loss??null,sampledAt:last?.sampledAt??null,historyObserved:!!p.observations,series:buckets.map(values=>!values.length||values.some(v=>v===null)?null:meanRtt(values))}
|
return {...p,status:last?.status??"unknown",rtt:last?.rtt??null,loss:last?.loss??null,sampledAt:last?.sampledAt??null,historyObserved:!!p.observations,observationError:last?.error??null,series:buckets.map(values=>!values.length||values.some(v=>v===null)?null:meanRtt(values))}
|
||||||
})
|
})
|
||||||
const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v)
|
const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v)
|
||||||
return {probes,sampledAt:active.length&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
return {probes,sampledAt:active.length&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react"
|
import type { Dispatch, SetStateAction } from "react"
|
||||||
|
|
||||||
export type UptimeTab = "probes" | "resources" | "speed"
|
export type UptimeTab = "probes" | "resources" | "speed"
|
||||||
export type ProbeStatusFilter = "all" | "up" | "warn" | "down"
|
export type ProbeStatusFilter = "all" | "up" | "warn" | "down" | "unknown"
|
||||||
export function readUptimeUrl(params: URLSearchParams) {
|
export function readUptimeUrl(params: URLSearchParams) {
|
||||||
const rawTab = params.get("tab")
|
const rawTab = params.get("tab")
|
||||||
const rawStatus = params.get("status")
|
const rawStatus = params.get("status")
|
||||||
const search = params.get("q") ?? ""
|
const search = params.get("q") ?? ""
|
||||||
const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes"
|
const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes"
|
||||||
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" ? rawStatus : "all"
|
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" || rawStatus === "unknown" ? rawStatus : "all"
|
||||||
let error: string | null = null
|
let error: string | null = null
|
||||||
let servers: string[] = []
|
let servers: string[] = []
|
||||||
if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) ||
|
if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) ||
|
||||||
(rawStatus !== null && !["up", "warn", "down"].includes(rawStatus)) ||
|
(rawStatus !== null && !["up", "warn", "down", "unknown"].includes(rawStatus)) ||
|
||||||
["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга."
|
["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга."
|
||||||
const rawServers = params.get("servers")
|
const rawServers = params.get("servers")
|
||||||
if (rawServers !== null) {
|
if (rawServers !== null) {
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ export interface PingProbeSnapshot {
|
|||||||
srcInterface: string
|
srcInterface: string
|
||||||
ok: boolean
|
ok: boolean
|
||||||
rttMs: number | null
|
rttMs: number | null
|
||||||
lossPct: number
|
lossPct: number | null
|
||||||
status: "up" | "warn" | "down"
|
status: "up" | "warn" | "down" | "unknown"
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {test,expect,type Page} from "@playwright/test"
|
||||||
|
async function setup(page:Page,known:boolean){
|
||||||
|
const server={id:1,name:"Источник",host:"192.0.2.1",type:"exit-node",enabled:true,country:"RU",site:"TEST",asn:"64512",status:"online",latency:1,os:"7",model:"CHR",sessions:0,wanUplinks:[]}
|
||||||
|
const time="2026-10-12T10:30:00Z",point=known?{sampledAt:time,rtt:null,loss:100,status:"down"}:{sampledAt:time,rtt:null,loss:null,status:"unknown",error:"RouterOS API error 401: Unauthorized"}
|
||||||
|
const probe={id:"probe",srcServerId:"1",name:"Проверка",target:"192.0.2.10",filter:"—",srcInterface:"",rtt:null,loss:known?100:null,status:known?"down":"unknown",series:[],enabled:true,showOnDashboard:true,observations:{samples:[point]}}
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
|
||||||
|
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname
|
||||||
|
const json=path==="/api/auth/config"?{required:false}:path==="/api/servers"?[server]:path==="/api/uptime/overview"?{probes:[probe],resources:[],rangeStartAt:"2026-10-12T10:00:00Z",rangeEndAt:"2026-10-12T11:00:00Z"}:path==="/api/uptime/speed-probes"?{probes:[]}:path==="/api/uptime/speed-test/runs"?{runs:[]}:{}
|
||||||
|
return r.fulfill({json})
|
||||||
|
})
|
||||||
|
await page.goto("/uptime?tab=probes")
|
||||||
|
}
|
||||||
|
for(const width of[1440,320])test(`Ping ${width}: API failure is no data, not measured loss`,async({page})=>{
|
||||||
|
await page.setViewportSize({width,height:1000});await setup(page,false)
|
||||||
|
await expect(page.getByText("Ошибка измерения: RouterOS API error 401: Unauthorized",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText("100%",{exact:true})).toHaveCount(0)
|
||||||
|
await page.getByRole("button",{name:/Нет данных/}).first().click();await expect(page).toHaveURL(/status=unknown/)
|
||||||
|
await expect(page.getByText("Ошибка измерения: RouterOS API error 401: Unauthorized",{exact:true})).toBeVisible()
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||||
|
await page.screenshot({path:`docs/ux-ui/screens/ping-measurement-unknown-${width}-2026-10-12.png`,fullPage:true})
|
||||||
|
})
|
||||||
|
test("Ping measured 100 percent loss remains a real down observation",async({page})=>{
|
||||||
|
await setup(page,true);await expect(page.getByText("100%",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText(/Ошибка измерения:/)).toHaveCount(0)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user