fix(uptime): показывать реальные наблюдения и пропуски RTT на дашборде
This commit is contained in:
1 parent
8f65d1620d
commit
9d3129372b
18 files changed
+169
-203
No files matched your search
@@ -1,129 +1,27 @@
|
||||
"use client"
|
||||
|
||||
const PALETTE = [
|
||||
"var(--chart-line-1)",
|
||||
"var(--chart-line-2)",
|
||||
"var(--chart-line-3)",
|
||||
"var(--chart-line-4)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
]
|
||||
|
||||
/** Легенда для демо-данных (ключи `msk`, `spb`, …). */
|
||||
const LEGACY_LABELS: Record<string, string> = {
|
||||
msk: "MSK",
|
||||
spb: "SPB",
|
||||
fra: "FRA",
|
||||
ams: "AMS",
|
||||
}
|
||||
|
||||
interface LatencyChartProps {
|
||||
series: Record<string, number[]>
|
||||
/** Подписи линий (ключ → короткое имя). Если нет — берётся legacy или сам ключ. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export function LatencyChart({ series, labels: labelsProp }: LatencyChartProps) {
|
||||
const W = 800
|
||||
const H = 220
|
||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
|
||||
const entries = Object.entries(series).filter(([, arr]) => arr.length > 0)
|
||||
const pointCount = entries.length ? Math.max(...entries.map(([, a]) => a.length), 2) : 60
|
||||
|
||||
const allVals = entries.flatMap(([, arr]) => arr)
|
||||
const maxVal = Math.ceil(Math.max(1, ...allVals) / 20) * 20 + 10
|
||||
|
||||
const xAt = (i: number, n: number) => pad.l + (i / Math.max(n - 1, 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1]
|
||||
|
||||
const tickIndices = [
|
||||
0,
|
||||
Math.floor((pointCount - 1) * 0.25),
|
||||
Math.floor((pointCount - 1) * 0.5),
|
||||
Math.floor((pointCount - 1) * 0.75),
|
||||
pointCount - 1,
|
||||
]
|
||||
|
||||
const labelForIndex = (i: number) => {
|
||||
const spanMin = 60
|
||||
const m = Math.round(spanMin * (1 - i / Math.max(pointCount - 1, 1)))
|
||||
return `-${m}м`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 220, display: "block" }}>
|
||||
{gridLines.map((p, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.l}
|
||||
x2={W - pad.r}
|
||||
y1={pad.t + ih * p}
|
||||
y2={pad.t + ih * p}
|
||||
style={{ stroke: "var(--border)" }}
|
||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8}
|
||||
y={pad.t + ih * p + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{Math.round(maxVal * (1 - p))}мс
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{tickIndices.map((i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={xAt(i, pointCount)}
|
||||
y={H - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{labelForIndex(i)}
|
||||
</text>
|
||||
))}
|
||||
{entries.map(([key, arr], idx) => {
|
||||
const n = arr.length
|
||||
const pts = arr.map((v, i) => `${xAt(i, n).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
return (
|
||||
<polyline
|
||||
key={key}
|
||||
points={pts}
|
||||
fill="none"
|
||||
style={{ stroke: color }}
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 pt-2 pl-11">
|
||||
{entries.map(([key], idx) => {
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
const leg =
|
||||
labelsProp?.[key] ?? LEGACY_LABELS[key] ?? key.replace(/^src-/, "")
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
||||
<span className="font-mono text-foreground">{leg}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
import {CartesianGrid,Line,LineChart,XAxis,YAxis} from "recharts"
|
||||
import {ChartContainer,ChartTooltip,ChartTooltipContent,type ChartConfig} from "@/components/ui/chart"
|
||||
import {formatRangeAgo} from "@/lib/fmt-rate"
|
||||
const PALETTE=["var(--chart-line-1)","var(--chart-line-2)","var(--chart-line-3)","var(--chart-line-4)","var(--chart-1)","var(--chart-2)","var(--chart-3)","var(--chart-4)"]
|
||||
const LEGACY_LABELS:Record<string,string>={msk:"MSK",spb:"SPB",fra:"FRA",ams:"AMS"}
|
||||
interface LatencyChartProps {series:Record<string,(number|null)[]>;labels?:Record<string,string>}
|
||||
/** Reuse the installed ChartContainer / multi-line composition of ReUI chart-23. */
|
||||
export function LatencyChart({series,labels:labelsProp}:LatencyChartProps){
|
||||
const entries=Object.entries(series).filter(([,values])=>values.some(v=>typeof v==="number"&&Number.isFinite(v)))
|
||||
const count=Math.max(1,...entries.map(([,values])=>values.length))
|
||||
const data=Array.from({length:count},(_,i)=>({time:formatRangeAgo(Math.round((1-i/Math.max(count-1,1))*60)),...Object.fromEntries(entries.map(([key,values])=>[key,typeof values[i]==="number"&&Number.isFinite(values[i])?values[i]:null]))}))
|
||||
const label=(key:string)=>labelsProp?.[key]??LEGACY_LABELS[key]??key.replace(/^src-/,"")
|
||||
const config=Object.fromEntries(entries.map(([key],index)=>[key,{label:label(key),color:PALETTE[index%PALETTE.length]}])) satisfies ChartConfig
|
||||
const dot=(key:string)=>function IsolatedDot({cx,cy,index}:{cx?:number;cy?:number;index?:number}){const values=series[key];if(index==null||cx==null||cy==null||values[index]==null||values[index-1]!=null||values[index+1]!=null)return <g/>;return <circle cx={cx} cy={cy} r={3} fill={config[key].color} data-latency-isolated="true"/>}
|
||||
if(!entries.length)return <p role="status" className="py-10 text-center text-sm text-muted-foreground">Нет измеренной истории RTT</p>
|
||||
return <div role="region" aria-label="История RTT по источникам" className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="aspect-auto h-[220px] w-full"><LineChart data={data} margin={{top:8,right:8,left:0,bottom:0}}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 8" stroke="var(--border)"/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fontSize:11}} tickMargin={8} minTickGap={16}/>
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fontSize:11}} width={52} tickFormatter={(v:number)=>String(v)+" мс"}/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={(value,name)=><div className="flex min-w-0 items-center gap-2 text-xs"><span>{config[String(name)]?.label??name}</span><span className="font-mono">{typeof value==="number"?String(value)+" мс":"—"}</span></div>}/>}/>
|
||||
{entries.map(([key])=><Line key={key} dataKey={key} type="linear" stroke={config[key].color} strokeWidth={1.6} connectNulls={false} dot={dot(key)} isAnimationActive={false}/>)}
|
||||
</LineChart></ChartContainer>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">{entries.map(([key])=><span key={key} data-latency-source={key} className="flex items-center gap-1.5 text-xs"><span aria-hidden className="h-0.5 w-3 rounded" style={{background:config[key].color}}/><span className="text-foreground">{label(key)}</span></span>)}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { PingProbe, Server, ServerType } from "@/lib/data"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -10,8 +11,8 @@ import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-g
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FilterIcon } from "lucide-react"
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
function formatLossPct(loss: number|null): string {
|
||||
if (loss===null || !Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ function TypeChip({ type }: { type: ServerType }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: DashboardProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
@@ -40,7 +41,7 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
<StatusDot status="unknown" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
@@ -79,7 +80,7 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
||||
}
|
||||
|
||||
interface DashboardActiveProbesDataGridProps {
|
||||
probes: PingProbe[]
|
||||
probes: DashboardProbe[]
|
||||
catalog: Server[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
@@ -93,7 +94,7 @@ function DashboardActiveProbesDataGrid({
|
||||
emptyTitle = "Нет активных проб",
|
||||
emptyDescription,
|
||||
}: DashboardActiveProbesDataGridProps) {
|
||||
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
|
||||
const columns = useMemo<CompactDataGridColumn<DashboardProbe>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "source",
|
||||
@@ -140,7 +141,7 @@ function DashboardActiveProbesDataGrid({
|
||||
cell: (p) => (
|
||||
<span
|
||||
className={cn(
|
||||
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
(p.loss??0) > 5 ? "text-red-500" : (p.loss??0) > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatLossPct(p.loss)}
|
||||
@@ -149,7 +150,7 @@ function DashboardActiveProbesDataGrid({
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
header: "60с",
|
||||
header: "RTT · 1 ч",
|
||||
enableSorting: false,
|
||||
headerClassName: "w-36",
|
||||
cell: (p) => {
|
||||
@@ -168,7 +169,7 @@ function DashboardActiveProbesDataGrid({
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<StatusBadge
|
||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user