fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import {
|
||||
@@ -27,9 +27,27 @@ export const LiveSection: FC = () => {
|
||||
return <LiveCard live={live} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many samples the live charts plot.
|
||||
*
|
||||
* The live endpoint returns the capture SO FAR, which grows without bound — a capture left running
|
||||
* over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail
|
||||
* is also the only part anyone watches live (the full series is what the saved recording is for),
|
||||
* so plot a bounded window and leave the rest to the detail view.
|
||||
*/
|
||||
const LIVE_WINDOW = 600;
|
||||
|
||||
/** Live graphs while a capture is armed: latency stack + throughput. */
|
||||
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||
const samples = live.data?.samples ?? [];
|
||||
const all = live.data?.samples;
|
||||
// Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so
|
||||
// an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart
|
||||
// rebuild either (the charts memoise on exactly this).
|
||||
const samples = useMemo(
|
||||
() =>
|
||||
all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []),
|
||||
[all],
|
||||
);
|
||||
// A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as
|
||||
// "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting".
|
||||
const error =
|
||||
@@ -58,6 +76,11 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||
<ChartBlock title={m.stats_throughput_title()}>
|
||||
<ThroughputChart samples={samples} />
|
||||
</ChartBlock>
|
||||
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.stats_live_window({ count: LIVE_WINDOW })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample
|
||||
// carries — native (queue/capture/submit/encode/send) and gamestream
|
||||
// (capture/encode/packetize/send) both stack sensibly.
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -85,6 +85,55 @@ function colorFor(name: string, i: number): string {
|
||||
return STAGE_COLORS[name] ?? PALETTE[i % PALETTE.length] ?? "#6c5bf3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared X-axis config for every chart here.
|
||||
*
|
||||
* `type="number"` + an explicit domain, NOT recharts' default category axis. As a category axis
|
||||
* every sample is one evenly-spaced slot, so a capture that idled for two minutes drew that gap as
|
||||
* a single step and the timeline was a lie — precisely the thing you are reading these charts to
|
||||
* find. As a number axis the spacing is the actual elapsed time.
|
||||
*/
|
||||
const timeAxis = {
|
||||
dataKey: "t",
|
||||
type: "number",
|
||||
domain: ["dataMin", "dataMax"],
|
||||
scale: "time",
|
||||
tick: axisTick,
|
||||
stroke: gridStroke,
|
||||
unit: "s",
|
||||
allowDecimals: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Split a capture at every session boundary and insert a gap between the pieces.
|
||||
*
|
||||
* A capture can span more than one session (`StatsSample.session_id`), and joining those samples
|
||||
* into one continuous line implies a continuity that never existed — the stream stopped and a
|
||||
* different client started a new one. Recharts breaks a line wherever a value is `null`, so one
|
||||
* spacer row between sessions renders the discontinuity without any per-chart special-casing.
|
||||
*/
|
||||
function withSessionBreaks<T extends { t: number }>(
|
||||
samples: StatsSample[],
|
||||
rows: T[],
|
||||
): (T | { t: number })[] {
|
||||
const out: (T | { t: number })[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) continue;
|
||||
const prev = samples[i - 1];
|
||||
const cur = samples[i];
|
||||
if (prev && cur && prev.session_id !== cur.session_id) {
|
||||
// A bare `t` row: every series key is absent ⇒ null ⇒ recharts lifts the pen.
|
||||
out.push({ t: row.t - 0.001 });
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Seconds since the capture began, as a number (see `timeAxis`). */
|
||||
const tSeconds = (s: StatsSample): number => s.t_ms / 1000;
|
||||
|
||||
/** Latency stacked-area (µs) — the "where does the time go" view. With `toggle`, a
|
||||
* p50/p99 switch flips every stage band between its median and tail. */
|
||||
export function LatencyChart({
|
||||
@@ -95,24 +144,42 @@ export function LatencyChart({
|
||||
toggle?: boolean;
|
||||
}) {
|
||||
const [p99, setP99] = useState(false);
|
||||
const names = stageNames(samples);
|
||||
const rows = samples.map((s) => {
|
||||
const row: Record<string, number> = { t: Math.round(s.t_ms / 1000) };
|
||||
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
|
||||
for (const n of names) {
|
||||
const st = byName.get(n);
|
||||
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const names = useMemo(() => stageNames(samples), [samples]);
|
||||
// Memoised: this walks every sample × every stage, and the live card re-renders it on a 2 s
|
||||
// poll. Without this the whole series was rebuilt on every unrelated render too.
|
||||
const rows = useMemo(() => {
|
||||
const built = samples.map((s) => {
|
||||
// `t` is declared on the type so the row satisfies `withSessionBreaks`' constraint;
|
||||
// the stage columns are added by name below.
|
||||
const row: Record<string, number> & { t: number } = { t: tSeconds(s) };
|
||||
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
|
||||
for (const n of names) {
|
||||
const st = byName.get(n);
|
||||
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
return withSessionBreaks(samples, built);
|
||||
}, [samples, names, p99]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{toggle && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setP99((v) => !v)}>
|
||||
{p99 ? m.stats_p99() : m.stats_p50()}
|
||||
</Button>
|
||||
// The button used to be labelled with the percentile currently PLOTTED while looking
|
||||
// like an action, so it read as "click to show p99" when p99 was already showing.
|
||||
// Two explicit options, with the active one pressed, says which is which.
|
||||
<div className="flex justify-end gap-1">
|
||||
{([false, true] as const).map((wantP99) => (
|
||||
<Button
|
||||
key={String(wantP99)}
|
||||
variant={p99 === wantP99 ? "default" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={p99 === wantP99}
|
||||
onClick={() => setP99(wantP99)}
|
||||
>
|
||||
{wantP99 ? m.stats_p99() : m.stats_p50()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChartFrame>
|
||||
@@ -121,7 +188,7 @@ export function LatencyChart({
|
||||
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
@@ -151,19 +218,26 @@ export function LatencyChart({
|
||||
|
||||
/** New vs repeat fps (left axis) + tx goodput Mb/s vs the configured target (right axis). */
|
||||
export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
|
||||
const rows = samples.map((s) => ({
|
||||
t: Math.round(s.t_ms / 1000),
|
||||
fps: s.fps,
|
||||
repeat: s.repeat_fps,
|
||||
mbps: s.mbps,
|
||||
// The configured encoder target (kbps → Mb/s) so goodput can be read against it.
|
||||
target: s.bitrate_kbps / 1000,
|
||||
}));
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
withSessionBreaks(
|
||||
samples,
|
||||
samples.map((s) => ({
|
||||
t: tSeconds(s),
|
||||
fps: s.fps,
|
||||
repeat: s.repeat_fps,
|
||||
mbps: s.mbps,
|
||||
// The configured encoder target (kbps → Mb/s) so goodput reads against it.
|
||||
target: s.bitrate_kbps / 1000,
|
||||
})),
|
||||
),
|
||||
[samples],
|
||||
);
|
||||
return (
|
||||
<ChartFrame>
|
||||
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
yAxisId="fps"
|
||||
tick={axisTick}
|
||||
@@ -233,13 +307,20 @@ export function HealthChart({
|
||||
samples: StatsSample[];
|
||||
kind?: string;
|
||||
}) {
|
||||
const rows = samples.map((s) => ({
|
||||
t: Math.round(s.t_ms / 1000),
|
||||
frames: s.frames_dropped,
|
||||
packets: s.packets_dropped,
|
||||
send: s.send_dropped,
|
||||
fec: s.fec_recovered,
|
||||
}));
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
withSessionBreaks(
|
||||
samples,
|
||||
samples.map((s) => ({
|
||||
t: tSeconds(s),
|
||||
frames: s.frames_dropped,
|
||||
packets: s.packets_dropped,
|
||||
send: s.send_dropped,
|
||||
fec: s.fec_recovered,
|
||||
})),
|
||||
),
|
||||
[samples],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{kind === "gamestream" && (
|
||||
@@ -253,7 +334,7 @@ export function HealthChart({
|
||||
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** ms → `m:ss`. */
|
||||
@@ -7,9 +8,9 @@ export function fmtDuration(ms: number): string {
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Locale-aware (see lib/format.ts) — a bare `toLocaleString` follows the BROWSER, not the app. */
|
||||
export function fmtTimestamp(unixMs: number): string {
|
||||
if (!unixMs) return "—";
|
||||
return new Date(unixMs).toLocaleString();
|
||||
return fmtDateTime(unixMs);
|
||||
}
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
|
||||
Reference in New Issue
Block a user