feat(web): the numbers behind "it's slow to start" and a way to clean up after a plugin

Three things the host already reports and the console never showed.

**Stream diagnostics.** `RuntimeStatus.stream` has carried the session bring-up
time, the last mid-stream resize cost, the client's FEC parity floor and the
packet size for as long as the endpoint has existed, and the dashboard showed
none of them. So "it takes ages to start" and "it hitches when I change
resolution" had no number attached anywhere in the console — you had to take a
stats capture to see a value the status endpoint was already returning. The two
timings are native-plane only and null until the first frame lands, so each
appears once it means something.

**Loss and FEC recovery while the capture runs.** The health chart existed but
only in the saved-recording view, which is backwards: dropped frames and FEC
recovery are what you watch a live capture for. It now sits under the latency and
throughput charts on the live card, keeping the GameStream caveat (only `frames`
is instrumented on that plane).

**Provider-owned library entries.** A plugin can sync entries into the library,
and the host then refuses to edit or delete them one at a time — correct, and
completely opaque once the plugin is gone: its games sit in the library with no
console-side way to remove them. `DELETE /library/provider/{provider}` is the
documented clean-uninstall path and nothing called it. There is a card now that
names each provider, counts what it owns, filters the grid to it, and removes its
entries in one go.

Also: the dashboard's PIN tile really does say "Waiting"/"None" now. The earlier
commit added the strings but the edit that was supposed to use them silently did
not apply, so the tile still rendered a bare "●". Caught by auditing every
message key for a call site — the other 543 are wired.

Verified in a browser: the providers card shows, counts, and filtering hides
non-provider entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:20:10 +02:00
co-authored by Claude Opus 5
parent 0751265105
commit b8f603c8a1
7 changed files with 216 additions and 9 deletions
+13
View File
@@ -72,6 +72,10 @@
"stream_codec": "Codec",
"stream_resolution": "Auflösung",
"stream_fps": "Bildrate",
"stream_first_frame": "Erstes Bild",
"stream_last_resize": "Letzte Größenänderung",
"stream_packet_size": "Paketgröße",
"stream_min_fec": "FEC-Minimum",
"stream_bitrate": "Bitrate",
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
@@ -254,6 +258,15 @@
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
"library_providers_title": "Von Plugins synchronisiert",
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
"library_provider_count": "{count} Einträge",
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
"library_create": "Hinzufügen",
"library_cancel": "Abbrechen",
+13
View File
@@ -72,6 +72,10 @@
"stream_codec": "Codec",
"stream_resolution": "Resolution",
"stream_fps": "Frame rate",
"stream_first_frame": "First frame",
"stream_last_resize": "Last resize",
"stream_packet_size": "Packet size",
"stream_min_fec": "FEC floor",
"stream_bitrate": "Bitrate",
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
@@ -254,6 +258,15 @@
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
"library_providers_title": "Synced by plugins",
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
"library_provider_count": "{count} entries",
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
"library_provider_purged": "Removed the entries synced by “{provider}”.",
"library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
"library_create": "Add",
"library_cancel": "Cancel",
+35 -2
View File
@@ -8,6 +8,7 @@ import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtNumber } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunningGames } from "./RunningGames";
@@ -73,8 +74,13 @@ export const DashboardView: FC<{
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
{/* The whole value used to be "●" or "—": no text, no state, colour
doing all the work — nothing for a screen reader to read out and
nothing for anyone who can't tell the two badges apart. */}
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
{s.pin_pending
? m.status_pin_waiting()
: m.status_pin_none()}
</Badge>
</CardContent>
</Card>
@@ -138,7 +144,34 @@ export const DashboardView: FC<{
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
value={`${fmtNumber(s.stream.bitrate_kbps / 1000, 1)} Mbps`}
/>
{/* Bring-up and reconfigure cost, the parity floor and the packet
size: the host has reported all four for as long as this
endpoint has existed and the console showed none of them, so
"it takes ages to start" and "it hitches when I resize" had no
number attached anywhere. Native-plane only — null on
GameStream and null until the first frame lands, so the two
timings appear only once they mean something. */}
{s.stream.time_to_first_frame_ms != null && (
<Field
label={m.stream_first_frame()}
value={`${fmtNumber(s.stream.time_to_first_frame_ms)} ms`}
/>
)}
{s.stream.last_resize_ms != null && (
<Field
label={m.stream_last_resize()}
value={`${fmtNumber(s.stream.last_resize_ms)} ms`}
/>
)}
<Field
label={m.stream_packet_size()}
value={`${fmtNumber(s.stream.packet_size)} B`}
/>
<Field
label={m.stream_min_fec()}
value={fmtNumber(s.stream.min_fec)}
/>
</dl>
) : (
+26 -5
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
import type { FC } from "react";
import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
@@ -21,11 +21,32 @@ import { customId } from "./helpers";
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
onEdit,
}) => {
export const LibraryGridSection: FC<{
onEdit: (entry: GameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: GameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
if (all) onEntries?.(all);
}, [all, onEntries]);
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
// list for the grid, and a second parameterised query would just be a second cache entry of the
// same data going stale independently.
const filtered = useMemo(
() =>
providerFilter
? {
...library,
data: all?.filter((e) => e.provider === providerFilter),
}
: library,
[library, all, providerFilter],
);
const remove = useDeleteCustomGame();
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
@@ -44,7 +65,7 @@ export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
return (
<LibraryGrid
library={library}
library={filtered}
onEdit={onEdit}
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
+104
View File
@@ -0,0 +1,104 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteProviderEntries,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* Provider-owned entries: who put them there, and how to get rid of them.
*
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
* delete individually — the host answers 409 and points at the provider's own reconcile. Which is
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
* documented clean-uninstall path and nothing called it.
*
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
*/
export const ProvidersCard: FC<{
entries: GameEntry[];
/** The provider currently filtered to, or null for "everything". */
active: string | null;
onFilter: (provider: string | null) => void;
}> = ({ entries, active, onFilter }) => {
const qc = useQueryClient();
const purge = useDeleteProviderEntries();
// Count per provider, in first-seen order — the list is small and operator-facing.
const counts = new Map<string, number>();
for (const e of entries) {
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
}
if (counts.size === 0) return null;
const onPurge = async (provider: string, count: number) => {
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
// The host emits `library.changed`, but don't wait for the round trip to redraw.
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
if (active === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
return (
<Card>
<CardHeader>
<CardTitle>{m.library_providers_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_providers_help()}
</p>
<div className="flex flex-col gap-2">
{[...counts.entries()].map(([provider, count]) => (
<div
key={provider}
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
>
<span className="font-medium">{provider}</span>
<Badge variant="secondary">
{m.library_provider_count({ count })}
</Badge>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={active === provider ? "default" : "outline"}
aria-pressed={active === provider}
onClick={() =>
onFilter(active === provider ? null : provider)
}
>
{active === provider
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
disabled={purge.isPending}
aria-label={m.library_provider_purge()}
onClick={() => onPurge(provider, count)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
+17 -1
View File
@@ -1,11 +1,13 @@
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
@@ -16,6 +18,10 @@ export const SectionLibrary: FC = () => {
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState<FormTarget | null>(null);
// The full list, lifted from the grid so the providers card can count owners without a second
// copy of the same query, plus which provider (if any) the grid is filtered to.
const [entries, setEntries] = useState<GameEntry[]>([]);
const [providerFilter, setProviderFilter] = useState<string | null>(null);
return (
<Section maxWidth={false}>
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => {
<SourceTogglesSection />
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
<ProvidersCard
entries={entries}
active={providerFilter}
onFilter={setProviderFilter}
/>
<LibraryGridSection
onEdit={(entry) => setTarget(entry)}
providerFilter={providerFilter}
onEntries={setEntries}
/>
</div>
</Section>
);
+8 -1
View File
@@ -9,7 +9,7 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { LatencyChart, ThroughputChart } from "./charts";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/**
@@ -76,6 +76,13 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
{/* Loss/recovery was only ever visible AFTER stopping and reopening the
saved recording — which is backwards: dropped frames and FEC recovery
are what you watch a live capture FOR. The `kind` note keeps the
GameStream caveat (only `frames` is instrumented there). */}
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} kind={live.data?.meta?.kind} />
</ChartBlock>
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
<p className="text-xs text-muted-foreground">
{m.stats_live_window({ count: LIVE_WINDOW })}