From b8f603c8a12a34272a5b65d92d8c4a46b581db6e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:24:03 +0200 Subject: [PATCH] feat(web): the numbers behind "it's slow to start" and a way to clean up after a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/messages/de.json | 13 +++ web/messages/en.json | 13 +++ web/src/sections/Dashboard/view.tsx | 37 +++++++- web/src/sections/Library/LibraryGrid.tsx | 31 +++++-- web/src/sections/Library/Providers.tsx | 104 +++++++++++++++++++++++ web/src/sections/Library/index.tsx | 18 +++- web/src/sections/Stats/LiveCard.tsx | 9 +- 7 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 web/src/sections/Library/Providers.tsx diff --git a/web/messages/de.json b/web/messages/de.json index 3687fd22..014437ee 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index 85d3f303..4e063544 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index 9e8eb89d..b0ce0996 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -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<{ {m.status_pin_pending()} + {/* 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. */} - {s.pin_pending ? "●" : "—"} + {s.pin_pending + ? m.status_pin_waiting() + : m.status_pin_none()} @@ -138,7 +144,34 @@ export const DashboardView: FC<{ /> + {/* 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 && ( + + )} + {s.stream.last_resize_ms != null && ( + + )} + + ) : ( diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx index 0cbf16e3..f51fd6d8 100644 --- a/web/src/sections/Library/LibraryGrid.tsx +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -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 ( 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(); + 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 ( + + + {m.library_providers_title()} + + +

+ {m.library_providers_help()} +

+
+ {[...counts.entries()].map(([provider, count]) => ( +
+ {provider} + + {m.library_provider_count({ count })} + +
+ + +
+
+ ))} +
+
+
+ ); +}; diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx index e8c979f2..05ff4944 100644 --- a/web/src/sections/Library/index.tsx +++ b/web/src/sections/Library/index.tsx @@ -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(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([]); + const [providerFilter, setProviderFilter] = useState(null); return (
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => { - setTarget(entry)} /> + + + setTarget(entry)} + providerFilter={providerFilter} + onEntries={setEntries} + />
); diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index 4828eee5..0d4ebfec 100644 --- a/web/src/sections/Stats/LiveCard.tsx +++ b/web/src/sections/Stats/LiveCard.tsx @@ -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 }> = ({ live }) => { + {/* 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). */} + + + {(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (

{m.stats_live_window({ count: LIVE_WINDOW })}