Fix port traffic wall chart refresh and first-sample visibility.

Trigger collect on start, normalize sample time bounds, and keep the uPlot series updating live.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 15:10:33 +08:00
parent efe8f58cd2
commit eea4043848
9 changed files with 240 additions and 54 deletions

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth_service import write_audit from .auth_service import write_audit
@ -56,10 +56,15 @@ def api_list_tasks(
@router.post("/tasks") @router.post("/tasks")
def api_create_task( def api_create_task(
body: PortTrafficTaskCreate, body: PortTrafficTaskCreate,
background_tasks: BackgroundTasks,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
out = create_task(db, body) out = create_task(db, body)
if body.start_now and out.status == "running":
from .port_traffic_runner import dispatch_collect
background_tasks.add_task(dispatch_collect, out.id)
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
@ -119,8 +124,16 @@ def api_delete_task(task_id: str, request: Request, db: Session = Depends(get_db
@router.post("/tasks/{task_id}/start") @router.post("/tasks/{task_id}/start")
def api_start_task(task_id: str, request: Request, db: Session = Depends(get_db)): def api_start_task(
task_id: str,
background_tasks: BackgroundTasks,
request: Request,
db: Session = Depends(get_db),
):
out = set_task_status(db, task_id, "running") out = set_task_status(db, task_id, "running")
from .port_traffic_runner import dispatch_collect
background_tasks.add_task(dispatch_collect, task_id)
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
@ -135,6 +148,46 @@ def api_start_task(task_id: str, request: Request, db: Session = Depends(get_db)
return out.model_dump() return out.model_dump()
@router.post("/tasks/{task_id}/collect-now")
def api_collect_now(
task_id: str,
background_tasks: BackgroundTasks,
request: Request,
db: Session = Depends(get_db),
):
task = get_task(db, task_id)
if task.status not in ("running", "paused", "draft", "stopped"):
raise HTTPException(status_code=400, detail="invalid_status")
# Force due: clear last end so claim accepts, ensure running for this round.
from .models import PortTrafficTask
from .port_traffic_runner import dispatch_collect
row = db.get(PortTrafficTask, task_id)
if not row:
raise HTTPException(status_code=404, detail="task_not_found")
if bool(row.collect_running):
return {"ok": True, "started": False, "reason": "already_collecting", **task.model_dump()}
if str(row.status) != "running":
row.status = "running"
row.last_collect_ended_at = None
row.updated_at = datetime.utcnow()
db.commit()
background_tasks.add_task(dispatch_collect, task_id)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.collect_now",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/collect-now",
status_code=200,
detail={"id": task_id},
)
out = get_task(db, task_id)
return {"ok": True, "started": True, **out.model_dump()}
@router.post("/tasks/{task_id}/pause") @router.post("/tasks/{task_id}/pause")
def api_pause_task(task_id: str, request: Request, db: Session = Depends(get_db)): def api_pause_task(task_id: str, request: Request, db: Session = Depends(get_db)):
out = set_task_status(db, task_id, "paused") out = set_task_status(db, task_id, "paused")

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@ -204,11 +204,10 @@ def set_task_status(db: Session, task_id: str, status: str) -> PortTrafficTaskOu
) )
if active <= 0: if active <= 0:
raise HTTPException(status_code=400, detail="no_active_targets") raise HTTPException(status_code=400, detail="no_active_targets")
# Allow scheduler/collect-now to fire immediately after start/resume.
task.last_collect_ended_at = None
task.status = status task.status = status
task.updated_at = _utcnow() task.updated_at = _utcnow()
if status in ("stopped", "paused"):
# leave collect_running for runner to finish; recovery clears stuck flags
pass
db.commit() db.commit()
db.refresh(task) db.refresh(task)
return _task_out(db, task) return _task_out(db, task)
@ -308,6 +307,15 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp
) )
def _as_naive_utc(value: datetime | None) -> datetime | None:
"""Normalize query bounds to naive UTC (DB columns are naive utcnow)."""
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def get_samples( def get_samples(
db: Session, db: Session,
*, *,
@ -319,10 +327,10 @@ def get_samples(
if not target: if not target:
raise HTTPException(status_code=404, detail="target_not_found") raise HTTPException(status_code=404, detail="target_not_found")
now = _utcnow() now = _utcnow()
if to_ts is None: to_ts = _as_naive_utc(to_ts) or now
to_ts = now from_ts = _as_naive_utc(from_ts) or (to_ts - timedelta(hours=1))
if from_ts is None: # Slight skew so just-written samples are not clipped by client clock.
from_ts = to_ts - timedelta(hours=1) to_ts = to_ts + timedelta(seconds=5)
rows = ( rows = (
db.query(PortTrafficSample) db.query(PortTrafficSample)
.filter( .filter(

View file

@ -103,6 +103,12 @@ const en = {
wallPort: "Interface", wallPort: "Interface",
pickPort: "Select interface", pickPort: "Select interface",
range: "Range", range: "Range",
collectNow: "Collect now",
collectStarted: "Collect triggered",
collectBusy: "Collect already running",
targetError: "Last interface error",
waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.",
waitAfterError: "Last collect failed — fix connectivity, then Collect now.",
kpi: { kpi: {
tasks: "Tasks", tasks: "Tasks",
running: "Running", running: "Running",

View file

@ -103,6 +103,12 @@ const zh = {
wallPort: "接口", wallPort: "接口",
pickPort: "选择接口", pickPort: "选择接口",
range: "时间范围", range: "时间范围",
collectNow: "立即采集",
collectStarted: "已触发采集",
collectBusy: "采集进行中",
targetError: "接口最近错误",
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
kpi: { kpi: {
tasks: "任务数", tasks: "任务数",
running: "运行中", running: "运行中",

View file

@ -3083,7 +3083,13 @@ pre {
} }
.pt-wall__chart { .pt-wall__chart {
min-height: 240px; min-height: 260px;
width: 100%;
}
.pt-wall__chart-wrap {
position: relative;
min-height: 260px;
width: 100%; width: 100%;
} }
@ -3097,13 +3103,19 @@ pre {
} }
.pt-wall__empty { .pt-wall__empty {
position: absolute;
inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 240px; padding: 16px 24px;
text-align: center;
line-height: 1.5;
color: var(--pt-muted); color: var(--pt-muted);
background: rgba(11, 18, 32, 0.72);
border: 1px dashed rgba(148, 163, 184, 0.28); border: 1px dashed rgba(148, 163, 184, 0.28);
border-radius: 8px; border-radius: 8px;
pointer-events: none;
} }
@media (max-width: 900px) { @media (max-width: 900px) {

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
collectPortTrafficNow,
createPortTrafficTask, createPortTrafficTask,
deletePortTrafficTask, deletePortTrafficTask,
discoverPortTrafficPorts, discoverPortTrafficPorts,
@ -100,7 +101,8 @@ export function PortTrafficPage() {
queryKey: queryKeys.portTrafficTargets(wallTaskId), queryKey: queryKeys.portTrafficTargets(wallTaskId),
queryFn: () => fetchPortTrafficTargets(wallTaskId), queryFn: () => fetchPortTrafficTargets(wallTaskId),
enabled: view === "wall" && Boolean(wallTaskId), enabled: view === "wall" && Boolean(wallTaskId),
staleTime: 3000, staleTime: 2000,
refetchInterval: POLL_MS,
}); });
const samplesQuery = useQuery({ const samplesQuery = useQuery({
@ -115,8 +117,12 @@ export function PortTrafficPage() {
}); });
}, },
enabled: view === "wall" && Boolean(wallTargetId), enabled: view === "wall" && Boolean(wallTargetId),
staleTime: 2000, staleTime: 1000,
refetchInterval: POLL_MS, refetchInterval: (q) => {
const n = q.state.data?.points?.length ?? 0;
// Faster while waiting for the first sample; then every 5s for live wall.
return n === 0 ? 2500 : POLL_MS;
},
}); });
const invalidateAll = () => { const invalidateAll = () => {
@ -159,6 +165,16 @@ export function PortTrafficPage() {
}, },
onError: (e: Error) => showError(e.message), onError: (e: Error) => showError(e.message),
}); });
const collectMut = useMutation({
mutationFn: collectPortTrafficNow,
onSuccess: (res) => {
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
invalidateAll();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallTaskId) });
void queryClient.invalidateQueries({ queryKey: ["portTrafficSamples"] });
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: deletePortTrafficTask, mutationFn: deletePortTrafficTask,
onSuccess: () => { onSuccess: () => {
@ -632,11 +648,32 @@ export function PortTrafficPage() {
<option value={24}>24h</option> <option value={24}>24h</option>
</select> </select>
</label> </label>
<button
type="button"
className="btn-primary"
disabled={!wallTaskId || collectMut.isPending}
onClick={() => collectMut.mutate(wallTaskId)}
>
{t("portTraffic.collectNow")}
</button>
</div> </div>
{selectedWallTarget?.last_error ? (
<p className="muted" style={{ marginBottom: 8 }}>
{t("portTraffic.targetError")}: {selectedWallTarget.last_error}
</p>
) : null}
<PortTrafficWall <PortTrafficWall
target={selectedWallTarget} target={selectedWallTarget}
points={samplesQuery.data?.points || []} points={samplesQuery.data?.points || []}
rangeLabel={`${rangeHours}h`} rangeLabel={`${rangeHours}h`}
loading={samplesQuery.isLoading || samplesQuery.isFetching}
hint={
!wallTargetId
? t("portTraffic.pickPort")
: selectedWallTarget?.last_error
? t("portTraffic.waitAfterError")
: t("portTraffic.waitSamples")
}
/> />
</div> </div>
) : null} ) : null}

View file

@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef } from "react";
import uPlot from "uplot"; import uPlot from "uplot";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types"; import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
import { formatSystemTime, parseApiTime } from "../../utils/time";
function formatBps(n: number): string { function formatBps(n: number): string {
const v = Math.abs(n); const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`; if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`; if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`;
if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`; if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`;
return `${n.toFixed(0)}`; return `${Math.round(n)}`;
} }
function formatBwLabel(bps: number): string { function formatBwLabel(bps: number): string {
@ -16,14 +17,42 @@ function formatBwLabel(bps: number): string {
return `${formatBps(bps)}bit/s`; return `${formatBps(bps)}bit/s`;
} }
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
const xs: number[] = [];
const ins: number[] = [];
const outs: number[] = [];
for (const p of points) {
const d = parseApiTime(p.ts);
if (!d) continue;
xs.push(Math.floor(d.getTime() / 1000));
ins.push(Number(p.in_bps) || 0);
outs.push(Number(p.out_bps) || 0);
}
// Single sample: stretch a short segment so the line is visible.
if (xs.length === 1) {
xs.push(xs[0] + 60);
ins.push(ins[0]);
outs.push(outs[0]);
}
return [xs, ins, outs];
}
function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800);
const height = Math.max(260, Math.min(440, Math.floor(width * 0.34)));
return { width, height };
}
type Props = { type Props = {
target: PortTrafficTarget | null; target: PortTrafficTarget | null;
points: PortTrafficSamplePoint[]; points: PortTrafficSamplePoint[];
rangeLabel: string; rangeLabel: string;
loading?: boolean;
hint?: string;
}; };
export function PortTrafficWall({ target, points, rangeLabel }: Props) { export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
const rootRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null); const plotRef = useRef<uPlot | null>(null);
const latest = points.length ? points[points.length - 1] : null; const latest = points.length ? points[points.length - 1] : null;
@ -38,30 +67,30 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
}; };
}, [latest, target]); }, [latest, target]);
// Create plot once; keep resize listener for lifetime of mount.
useEffect(() => { useEffect(() => {
const el = rootRef.current; const el = mountRef.current;
if (!el) return; if (!el) return;
const xs = points.map((p) => Math.floor(new Date(p.ts).getTime() / 1000)); const { width, height } = chartSize(el);
const inSeries = points.map((p) => p.in_bps);
const outSeries = points.map((p) => p.out_bps);
const opts: uPlot.Options = { const opts: uPlot.Options = {
width: Math.max(320, el.clientWidth || 800), width,
height: Math.max(220, Math.min(420, Math.floor((el.clientWidth || 800) * 0.32))), height,
series: [ series: [
{}, {},
{ {
label: "In", label: "In bit/s",
stroke: "#2dd4bf", stroke: "#2dd4bf",
width: 2, width: 2,
points: { show: false }, fill: "rgba(45, 212, 191, 0.12)",
points: { show: true, size: 5, fill: "#2dd4bf" },
}, },
{ {
label: "Out", label: "Out bit/s",
stroke: "#f59e0b", stroke: "#f59e0b",
width: 2, width: 2,
points: { show: false }, fill: "rgba(245, 158, 11, 0.10)",
points: { show: true, size: 5, fill: "#f59e0b" },
}, },
], ],
axes: [ axes: [
@ -75,34 +104,34 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 }, grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" }, ticks: { stroke: "rgba(148,163,184,0.35)" },
values: (_u, splits) => splits.map((v) => formatBps(v)), values: (_u, splits) => splits.map((v) => formatBps(v)),
size: 56, size: 64,
label: "bit/s",
labelFont: "12px sans-serif",
labelSize: 14,
}, },
], ],
scales: { scales: {
x: { time: true }, x: { time: true },
y: {
auto: true,
range: (_u, min, max) => {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(10, Math.abs(max) * 0.25 || 10);
return [Math.max(0, min - pad), max + pad];
}
return [Math.max(0, min * 0.95), max * 1.05];
},
},
}, },
legend: { show: true }, legend: { show: true },
cursor: { cursor: { drag: { x: true, y: false } },
drag: { x: true, y: false },
},
}; };
plotRef.current?.destroy(); plotRef.current = new uPlot(opts, [[], [], []], el);
plotRef.current = null;
if (xs.length === 0) {
el.replaceChildren();
return;
}
plotRef.current = new uPlot(opts, [xs, inSeries, outSeries], el);
const onResize = () => { const onResize = () => {
if (!plotRef.current || !rootRef.current) return; if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize({ plotRef.current.setSize(chartSize(mountRef.current));
width: Math.max(320, rootRef.current.clientWidth),
height: Math.max(220, Math.min(420, Math.floor(rootRef.current.clientWidth * 0.32))),
});
}; };
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
return () => { return () => {
@ -110,8 +139,19 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
plotRef.current?.destroy(); plotRef.current?.destroy();
plotRef.current = null; plotRef.current = null;
}; };
}, []);
// Push live samples into the existing chart.
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
const data = toSeries(points);
plot.setData(data);
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
}, [points]); }, [points]);
const empty = points.length === 0;
return ( return (
<div className="pt-wall"> <div className="pt-wall">
<div className="pt-wall__head"> <div className="pt-wall__head">
@ -119,7 +159,10 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
<span className="pt-wall__ne">{target ? target.ne_name || target.ne_ip || "—" : "—"}</span> <span className="pt-wall__ne">{target ? target.ne_name || target.ne_ip || "—" : "—"}</span>
<span className="pt-wall__if">{target?.ifname || "Select interface"}</span> <span className="pt-wall__if">{target?.ifname || "Select interface"}</span>
</div> </div>
<div className="pt-wall__range">{rangeLabel}</div> <div className="pt-wall__range">
{rangeLabel}
{latest?.ts ? ` · last ${formatSystemTime(latest.ts)}` : ""}
</div>
</div> </div>
<div className="pt-wall__kpis"> <div className="pt-wall__kpis">
<div className="pt-wall__kpi"> <div className="pt-wall__kpi">
@ -149,8 +192,13 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
</div> </div>
</div> </div>
</div> </div>
<div className="pt-wall__chart" ref={rootRef}> <div className="pt-wall__chart-wrap">
{!points.length ? <div className="pt-wall__empty">No samples in range</div> : null} <div className="pt-wall__chart" ref={mountRef} />
{empty ? (
<div className="pt-wall__empty">
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
</div>
) : null}
</div> </div>
</div> </div>
); );

View file

@ -816,6 +816,12 @@ export const pausePortTrafficTask = (taskId: string) =>
export const stopPortTrafficTask = (taskId: string) => export const stopPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {}); apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
export const collectPortTrafficNow = (taskId: string) =>
apiPost<PortTrafficTask & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`,
{},
);
export const deletePortTrafficTask = (taskId: string) => export const deletePortTrafficTask = (taskId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`); apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);

View file

@ -21,14 +21,24 @@ function normalizeTimeInput(value: string | number | Date, options?: FormatTimeO
return `${text.replace(" ", "T")}Z`; return `${text.replace(" ", "T")}Z`;
} }
/** Parse API timestamps; naive ISO strings are treated as UTC. */
export function parseApiTime(
value: string | number | Date | null | undefined,
options?: FormatTimeOptions,
): Date | null {
if (value === null || value === undefined) return null;
const normalized = normalizeTimeInput(value, options);
const d = normalized instanceof Date ? normalized : new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatSystemTime( export function formatSystemTime(
value: string | number | Date | null | undefined, value: string | number | Date | null | undefined,
options?: FormatTimeOptions, options?: FormatTimeOptions,
): string { ): string {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
const normalized = normalizeTimeInput(value, options); const d = parseApiTime(value, options);
const d = normalized instanceof Date ? normalized : new Date(normalized); if (!d) return String(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleString(undefined, { return d.toLocaleString(undefined, {
hour12: false, hour12: false,
timeZone: systemTimeZone, timeZone: systemTimeZone,