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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
Reference in New Issue
Block a user