From e30d94573a75867c9a7c9206ce3872b068da18d4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:08:28 +0200 Subject: [PATCH] fix(web): the logs come back after a host restart, and eight more that quietly lied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logs page died permanently every time the host restarted — which the console's own update flow does. The host's log ring restarts at seq 1 while the page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh ring is not an error, it is an empty page forever: no error, no dropped badge, stale lines on screen, and nothing short of a full reload to get out. A restart always breaks the poll first, so a failed poll now triggers a re-read from the start of the ring, and a page whose newest entry is older than what we hold is recognised as the sequence having restarted. Follow mode also stopped following at exactly the wrong moment. The autoscroll effect was keyed on the rendered row count, which pins at the 1000-row DOM cap — so once the log got busy enough to matter, the effect never re-ran again. It is keyed on the newest rendered seq now. And pausing now actually pauses: stopping the interval left React Query's focus/reconnect refetches landing, which evicted the very lines the operator had paused on. The rest: - A plugin could white-screen the whole console by registering `icon: "constructor"`. The icon map is a plain object, so the inherited key resolved to `Object`, which is truthy — the fallback never fired and React was handed `Object` as a component, from inside the app shell. - Saving a display arrangement deleted the saved position of every device that was not connected at that moment: the host replaces the whole map, and we only ever sent the displays we could see. - Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits the Custom block was holding, then cleared the "unsaved" badge so there was no trace of it. Those three apply on top of the SAVED policy now. - Saving the Custom block put the streamed-screen pin back to whatever it was when the form was seeded, undoing a change made in the picker below it. - "End now" on a running game calls the host's only stop, which ends EVERY live session; on a grace row with no app id it ended every waiting game. Both say so first now, when there is more than one to lose. - Edit and Delete were offered on library entries owned by a provider plugin, which the host refuses with 409 — silently. They are attributed instead. - An install whose first poll failed never polled again, and one whose host restarted spun forever with no way to dismiss it. - Submitting a second pairing PIN showed the previous attempt's "PIN sent" before a digit was typed, and the paired list it points you at never refreshed. - The streamed-screen picker claimed an env pin during every slow load, and rows that cannot be picked now look that way instead of silently eating the click. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 6 + web/messages/en.json | 6 + web/src/api/plugins.ts | 16 ++- web/src/api/store.ts | 22 +++- web/src/sections/Dashboard/RunningGames.tsx | 8 +- web/src/sections/Dashboard/index.tsx | 36 +++++- web/src/sections/Displays/DisplayCard.tsx | 116 ++++++++++++++---- web/src/sections/Displays/MonitorCard.tsx | 14 ++- web/src/sections/Library/GameCard.tsx | 14 ++- web/src/sections/Logs/LogsCard.tsx | 101 +++++++++++++-- .../sections/Pairing/MoonlightPairingCard.tsx | 10 +- web/src/sections/Store/JobProgress.tsx | 28 ++++- 12 files changed, 327 insertions(+), 50 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index 0e49c338..419c5e4c 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -209,6 +209,7 @@ "library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.", "library_field_players": "Spieler", "library_details_legend": "Details (optional)", + "library_owned_by": "über {provider}", "library_save": "Speichern", "library_create": "Hinzufügen", "library_cancel": "Abbrechen", @@ -247,6 +248,7 @@ "logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.", "logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten", "logs_download": "Logs herunterladen", + "logs_stalled": "Log-Abruf fehlgeschlagen — die zuletzt empfangenen Zeilen bleiben sichtbar.", "logs_share": "Logs teilen", "logs_copy": "Logs in die Zwischenablage kopieren", "logs_copied": "Logs in die Zwischenablage kopiert", @@ -396,6 +398,8 @@ "store_job_done_uninstall": "Entfernt.", "store_job_failed": "Der Vorgang ist fehlgeschlagen.", "store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.", + "store_job_lost": "Dieser Vorgang ist nicht mehr auffindbar", + "store_job_lost_hint": "Der Host wurde währenddessen neu gestartet. Im Tab „Installiert“ siehst du, ob er durchgelaufen ist.", "store_job_log": "Log anzeigen", "store_job_dismiss": "Ausblenden", "store_phase_queued": "In der Warteschlange", @@ -413,6 +417,8 @@ "games_state_exited": "Beendet", "games_state_grace": "Wartet auf Client", "games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt", + "games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?", + "action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?", "games_end_now": "Jetzt beenden", "session_game_title": "Wenn ein Spiel oder eine Sitzung endet", "session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.", diff --git a/web/messages/en.json b/web/messages/en.json index b7268007..d1f3aa0a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -209,6 +209,7 @@ "library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.", "library_field_players": "Players", "library_details_legend": "Details (optional)", + "library_owned_by": "via {provider}", "library_save": "Save", "library_create": "Add", "library_cancel": "Cancel", @@ -247,6 +248,7 @@ "logs_empty": "No log entries match — adjust the filter or wait for host activity.", "logs_dropped": "Some entries were evicted before they could be fetched", "logs_download": "Download logs", + "logs_stalled": "Log polling failed — showing the last lines received.", "logs_share": "Share logs", "logs_copy": "Copy logs to clipboard", "logs_copied": "Logs copied to clipboard", @@ -396,6 +398,8 @@ "store_job_done_uninstall": "Removed.", "store_job_failed": "The job failed.", "store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.", + "store_job_lost": "Lost track of this job", + "store_job_lost_hint": "The host restarted while it ran. Check the Installed tab to see whether it finished.", "store_job_log": "Show log", "store_job_dismiss": "Dismiss", "store_phase_queued": "Queued", @@ -413,6 +417,8 @@ "games_state_exited": "Ended", "games_state_grace": "Waiting for client", "games_closing_in": "Its client is gone — closing in {time} unless it comes back", + "games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?", + "action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?", "games_end_now": "End now", "session_game_title": "When a game or a session ends", "session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.", diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index abb911ea..5ac963d3 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -46,9 +46,19 @@ const ICONS: Record = { clapperboard: Clapperboard, }; -/** Resolve a registered icon name to a component (Puzzle fallback). */ -export const pluginIcon = (name?: string): LucideIcon => - (name ? ICONS[name] : undefined) ?? Puzzle; +/** + * Resolve a registered icon name to a component (Puzzle fallback). + * + * `name` comes from a plugin's own registration, so it is untrusted input to a lookup on a plain + * object — and a plain object inherits from Object.prototype. `ICONS["constructor"]` is `Object`, + * which is truthy, so a `?? Puzzle` fallback never fires and React is handed `Object` as a + * component: it throws out of render, and because this runs inside the AppShell nav that takes + * down every page of the console. `Object.hasOwn` keeps the lookup to keys we actually declared. + */ +export const pluginIcon = (name?: string): LucideIcon => { + if (!name || !Object.hasOwn(ICONS, name)) return Puzzle; + return ICONS[name] ?? Puzzle; +}; /** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */ export function usePlugins() { diff --git a/web/src/api/store.ts b/web/src/api/store.ts index f3745877..0092c5cd 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -209,14 +209,34 @@ export function useStoreRuntime() { /** * A single install/uninstall job, polled once a second while it runs and left alone once it * settles. Pass `null` to park the query (no job in flight). + * + * The interval keys off "not finished yet" rather than off `state === "running"`. `data` is + * undefined in two live cases — the first poll has not landed, and the first poll FAILED — and + * treating those as "stop polling" wedged the card: an install whose very first poll lost the race + * with a busy host never polled again and the operator saw nothing at all, while an install that + * restarts the runner (every successful one does) could drop a poll mid-flight. + * + * The failure count bounds it: jobs live in host memory, so a host restart makes the id 404 for + * good, and something has to stop asking. */ +const JOB_POLL_MS = 1_000; +const JOB_MAX_FAILURES = 15; + export function useStoreJob(id: string | null) { return useQuery({ queryKey: storeKeys.job(id ?? ""), queryFn: () => apiFetch(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`), enabled: id !== null, - refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false), + refetchInterval: (q) => { + const state = q.state.data?.state; + if (state === "done" || state === "failed") return false; + if (q.state.fetchFailureCount > JOB_MAX_FAILURES) return false; + return JOB_POLL_MS; + }, + // A job that vanished with its host is gone for good; a transient blip is not. Retry a few + // times per poll so a runner restart doesn't surface as an error card. + retry: 3, }); } diff --git a/web/src/sections/Dashboard/RunningGames.tsx b/web/src/sections/Dashboard/RunningGames.tsx index a99ff1ea..260483a3 100644 --- a/web/src/sections/Dashboard/RunningGames.tsx +++ b/web/src/sections/Dashboard/RunningGames.tsx @@ -31,11 +31,13 @@ export const RunningGames: FC<{ - {games.map((g) => ( + {games.map((g, i) => ( onEnd(g)} diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index ef7cb246..beec51dc 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -9,6 +9,7 @@ import { useStopSession, } from "@/api/gen/session/session"; import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; import { DashboardView } from "./view"; export const SectionDashboard: FC = () => { @@ -33,23 +34,52 @@ export const SectionDashboard: FC = () => { * game whose session is still live ends by stopping that session (what then happens to the game * follows the operator's policy — stopping a session is not licence to close a game), while a * game already waiting out its reconnect window has no session left to stop and is ended directly. + * + * Both paths are wider than the row they are attached to, and neither used to say so: + * + * - `DELETE /session` is the host's ONLY stop and it tears down every live session + * (mgmt/session.rs calls `quit_session` AND `session_status::stop_all_quit`). With two people + * streaming, "End now" on one row kicked both. There is no per-session stop to call instead, + * so the honest fix is to name the blast radius before doing it. + * - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace + * row for an operator-typed command carries no `app_id` — so that row ended all of them. */ const onEndGame = (game: ActiveGame) => { + const games = status.data?.games ?? []; if (game.state === "grace") { + const waiting = games.filter((g) => g.state === "grace").length; + if ( + !game.app_id && + waiting > 1 && + !confirm(m.games_end_all_waiting_confirm({ count: waiting })) + ) + return; endGame.mutate( { data: { app_id: game.app_id ?? null } }, { onSuccess: invalidate }, ); - } else { - stop.mutate(undefined, { onSuccess: invalidate }); + return; } + if (!confirmStopAll()) return; + stop.mutate(undefined, { onSuccess: invalidate }); + }; + + /** Shared by "End now" on a live row and the card's own Stop-session button: with more than one + * session live, stopping is not a per-client action and the operator has to know that. */ + const confirmStopAll = (): boolean => { + const active = status.data?.active_sessions ?? 0; + if (active <= 1) return true; + return confirm(m.action_stop_session_all_confirm({ count: active })); }; return ( stop.mutate(undefined, { onSuccess: invalidate })} + onStopSession={() => { + if (!confirmStopAll()) return; + stop.mutate(undefined, { onSuccess: invalidate }); + }} onRequestIdr={() => idr.mutate(undefined)} onEndGame={onEndGame} isStopping={stop.isPending} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 3f1f83c8..7ce0dedf 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -7,6 +7,7 @@ import { type MouseEvent, type ReactNode, useEffect, + useMemo, useRef, useState, } from "react"; @@ -96,6 +97,47 @@ export const DisplaySection: FC = () => { }, ); + /** + * Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits + * along for the ride. + * + * These three controls apply immediately by design, but they used to send `{...draft}`: flipping + * DDC while the Custom block held unsaved edits committed those edits too, and the shared + * `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so + * the operator got a policy they never saved with no trace it had happened. Send the axis on top + * of the last SAVED policy, and merge only that axis back into the draft. + */ + /** + * Save the hand-edited Custom block. + * + * `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this + * form — but it is a field of the same policy object, so a draft seeded before the operator + * changed the streamed screen still carried the OLD value and Save quietly put it back. Defer + * that one axis to whatever the server currently reports. + */ + const saveDraft = () => { + if (!draft) return; + apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor }); + }; + + const applyAxis = (patch: Partial) => { + const base = seeded.current ?? draft; + if (!base) return; + // Reflect the flip straight away, keeping every other unsaved edit intact. + setDraft((d) => (d ? { ...d, ...patch } : d)); + save.mutate( + { data: { ...base, ...patch } }, + { + onSuccess: (res) => { + seeded.current = res.settings; + setDraft((d) => (d ? { ...d, ...patch } : res.settings)); + qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); + toast.success(m.display_settings_saved()); + }, + }, + ); + }; + // Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental // toggle), so the draft can silently diverge from what the host is actually running. Reading the // ref during render is safe here because every write to it is paired with a `setDraft`, so a @@ -144,6 +186,8 @@ export const DisplaySection: FC = () => { presets={q.data.presets} customPresets={q.data.custom_presets} apply={apply} + applyAxis={applyAxis} + saveDraft={saveDraft} busy={save.isPending} dirty={dirty} revert={revert} @@ -181,6 +225,10 @@ const DisplayForm: FC<{ presets: { id: string; summary: string; fields: EffectivePolicy }[]; customPresets: CustomPreset[]; apply: (p: DisplayPolicy) => void; + /** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */ + applyAxis: (patch: Partial) => void; + /** Commit the Custom block, deferring axes this form does not own to the server's value. */ + saveDraft: () => void; busy: boolean; /** The draft differs from what the host has stored — drives the save bar + the discard guard. */ dirty: boolean; @@ -193,6 +241,8 @@ const DisplayForm: FC<{ presets, customPresets, apply, + applyAxis, + saveDraft, busy, dirty, revert, @@ -637,7 +687,7 @@ const DisplayForm: FC<{ {m.display_revert()} )} - @@ -654,11 +704,7 @@ const DisplayForm: FC<{ options={["auto", "dedicated"]} labels={GAME_SESSION_LABEL} disabled={busy} - onPick={(v) => { - const next = { ...draft, game_session: v as GameSession }; - setDraft(next); - apply(next); - }} + onPick={(v) => applyAxis({ game_session: v as GameSession })} /> @@ -671,11 +717,7 @@ const DisplayForm: FC<{ offLabel={m.display_ddc_disabled()} onLabel={m.display_ddc_enabled()} busy={busy} - onSet={(on) => { - const next = { ...draft, ddc_power_off: on }; - setDraft(next); - apply(next); - }} + onSet={(on) => applyAxis({ ddc_power_off: on })} /> { - const next = { ...draft, pnp_disable_monitors: on }; - setDraft(next); - apply(next); - }} + onSet={(on) => applyAxis({ pnp_disable_monitors: on })} /> {/* What's in force right now */} @@ -986,22 +1024,44 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ }) => { const qc = useQueryClient(); const saveLayout = useSetDisplayLayout(); - // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). - const arrangeable = displays.filter((d) => d.identity_slot != null); + const settings = useGetDisplaySettings(); + // Every position the host has on file — including devices that are not connected right now. + // `PUT /display/layout` REPLACES the whole map (`with_manual_layout` in pf-vdisplay builds a + // fresh `Layout`), so anything missing from our payload is deleted. Seeding only from the live + // displays therefore wiped the saved placement of every device that happened to be offline. + const saved = settings.data?.settings.layout?.positions; - // Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions. + // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). + const arrangeable = useMemo( + () => displays.filter((d) => d.identity_slot != null), + [displays], + ); + // Local edit buffer keyed by identity-slot string → {x, y}. `arrangeable` is memoised, and React + // Query's structural sharing keeps `displays` identity-stable across polls that changed nothing, + // so this effect runs when the set of displays actually changes rather than on every poll. It is + // idempotent regardless — it only ever fills in slots it has not seen before. const [pos, setPos] = useState | null>(null); useEffect(() => { - if (pos === null && arrangeable.length > 0) { - const seed: Record = {}; - for (const d of arrangeable) - seed[String(d.identity_slot)] = { x: d.x, y: d.y }; - setPos(seed); - } - }, [arrangeable, pos]); + if (arrangeable.length === 0) return; + setPos((prev) => { + // Seed a display the first time we see it, and never re-seed one the operator may have + // since edited: a display that appears mid-edit used to be left out of the buffer entirely + // and so dropped from the save. + const next = { ...(prev ?? {}) }; + let changed = prev === null; + for (const d of arrangeable) { + const k = String(d.identity_slot); + if (!(k in next)) { + next[k] = { x: d.x, y: d.y }; + changed = true; + } + } + return changed ? next : prev; + }); + }, [arrangeable]); if (arrangeable.length < 2) return null; const cur = pos ?? {}; @@ -1013,7 +1073,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ const onSave = () => saveLayout.mutate( - { data: { positions: cur } }, + // Saved-first, edits on top: the host replaces the whole map, so an absent device's + // placement survives only if we send it back. + { data: { positions: { ...saved, ...cur } } }, { onSuccess: () => { qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }); diff --git a/web/src/sections/Displays/MonitorCard.tsx b/web/src/sections/Displays/MonitorCard.tsx index 47e1fb19..663851fe 100644 --- a/web/src/sections/Displays/MonitorCard.tsx +++ b/web/src/sections/Displays/MonitorCard.tsx @@ -10,9 +10,9 @@ import { useSetDisplaySettings, } from "@/api/gen/display/display"; import type { ApiMonitorInfo } from "@/api/gen/model"; +import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { QueryState } from "@/components/query-state"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; @@ -41,7 +41,11 @@ export const MonitorCard: FC = () => { // `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's // environment is read-only here: offering controls that silently lose to the env would be worse // than saying so. - const envLocked = !!pinned && policy?.capture_monitor !== pinned; + // + // Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed) + // `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env + // pin that may not exist and go read-only on every slow load. + const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned; // The host says whether it can honor a pin at all. Windows enumerates its heads but has no // backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to // offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was @@ -85,7 +89,11 @@ export const MonitorCard: FC = () => { className={cn( "flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors", selected ? "border-primary bg-primary/5" : "hover:bg-muted/50", - (busy || locked) && "cursor-not-allowed opacity-60", + // `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own + // virtual displays. It was styled exactly like a selectable row and silently swallowed + // every click; it is listed so "why isn't my monitor here?" has an answer, so it has to + // LOOK unavailable too. + (busy || locked || !onSelect) && "cursor-not-allowed opacity-60", )} > diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx index 58d6f144..a3e3515d 100644 --- a/web/src/sections/Library/GameCard.tsx +++ b/web/src/sections/Library/GameCard.tsx @@ -40,7 +40,12 @@ export const GameCard: FC = ({ onDelete, deleting, }) => { - const isCustom = game.store === "custom"; + // Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a + // provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it + // (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the + // buttons produced a failure the card never surfaced. Provider-owned entries are attributed + // instead. + const isCustom = game.store === "custom" && !game.provider; // Track which sources have failed so the can step down portrait → header → placeholder. const [failed, setFailed] = useState>({}); @@ -79,6 +84,13 @@ export const GameCard: FC = ({ {game.platform} )} + {/* Who owns this entry, when it isn't the operator — the reason the edit/delete + buttons are absent here and present on the card next to it. */} + {game.provider && ( + + {m.library_owned_by({ provider: game.provider })} + + )} {isCustom && (
diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 2953c5be..640b6bcd 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -49,6 +49,8 @@ export const LogsSection: FC = () => { const [follow, setFollow] = useState(true); const [dropped, setDropped] = useState(false); const [shareMode, setShareMode] = useState(null); + // Set while a poll has failed and we have not yet re-read the ring from the start. + const [resync, setResync] = useState(false); // Probed after mount: the server render has no `navigator`, and guessing there would mismatch // on hydration. Until then the share button is simply absent. @@ -58,22 +60,58 @@ export const LogsSection: FC = () => { const query = useLogsGet( { after: cursor > 0 ? cursor : undefined }, - { query: { refetchInterval: follow ? 2_000 : false } }, + { + query: { + refetchInterval: follow ? 2_000 : false, + // Pausing must actually pause. Stopping only the interval left React Query's default + // focus/reconnect refetches landing, and the append effect consumed them + // unconditionally — so tabbing away and back evicted the lines the operator had + // paused on, from behind the pause button. + refetchOnWindowFocus: follow, + refetchOnReconnect: follow, + }, + }, ); + // Resync after the host goes away and comes back. + // + // The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it + // got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently + // EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with + // no error, no dropped badge and no way back short of a full reload. The console's own update + // flow restarts the host, so this was reachable from two clicks away. + // + // A restart always breaks the poll first, so a failed query is the trigger: on the next success + // we re-read from the start of the ring once and let the effect below decide whether the + // sequence actually regressed. + const failed = query.isError; + useEffect(() => { + if (failed) setResync(true); + }, [failed]); + useEffect(() => { + if (resync && cursor !== 0) setCursor(0); + }, [resync, cursor]); + const data = query.data; useEffect(() => { if (!data || data.entries.length === 0) return; setEntries((prev) => { - // Only append entries newer than what we already hold — dedup by the monotonic `seq`. - // Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from - // appending the same page twice (duplicate rows + duplicate React keys). const lastSeq = prev.at(-1)?.seq ?? -1; + // A page whose newest entry is OLDER than what we already hold can only mean the host's + // sequence restarted underneath us — the buffer describes a host that no longer exists, + // so replace it wholesale rather than filtering every new line away as "already seen". + const newest = data.entries.at(-1)?.seq ?? -1; + if (newest < lastSeq) return data.entries.slice(-KEEP); + // Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a + // double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending + // the same page twice (duplicate rows + duplicate React keys), and makes the post-resync + // re-read from 0 a no-op when the host did NOT restart. const fresh = data.entries.filter((e) => e.seq > lastSeq); return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev; }); setDropped((d) => d || data.dropped); setCursor(data.next); + setResync(false); }, [data]); // The card hands back the entries its filters currently match, so an export carries exactly what @@ -100,6 +138,9 @@ export const LogsSection: FC = () => { }} shareMode={shareMode} dropped={dropped} + error={query.error} + isLoading={query.isLoading} + onRetry={() => query.refetch()} /> ); }; @@ -118,6 +159,10 @@ export const LogsCard: FC<{ onShare: (shown: LogEntry[]) => void; shareMode: ShareMode | null; dropped: boolean; + /** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */ + error?: unknown; + isLoading?: boolean; + onRetry?: () => void; }> = ({ entries, follow, @@ -127,6 +172,9 @@ export const LogsCard: FC<{ onShare, shareMode, dropped, + error, + isLoading, + onRetry, }) => { const [minLevel, setMinLevel] = useState("DEBUG"); const [search, setSearch] = useState(""); @@ -146,12 +194,22 @@ export const LogsCard: FC<{ const visible = useMemo(() => matched.slice(-SHOW), [matched]); const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy(); - // Keep the tail in view while following (entries are append-only, so length is a good signal). + // Keep the tail in view while following. + // + // Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`, + // so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then + // stopped re-running and follow-mode quietly stopped following — exactly when the log is busy + // enough to need it. The newest seq keeps changing for as long as lines arrive. + const newestVisible = visible.at(-1)?.seq ?? -1; + // NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and + // offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived. + // Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode + // quietly stops following. The same warning was here before, on `visible.length`. useEffect(() => { if (!follow) return; const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [follow, visible.length]); + }, [follow, newestVisible]); return ( @@ -222,12 +280,41 @@ export const LogsCard: FC<{
+ {/* A failing poll while lines are already on screen keeps them there — during a host + restart the last lines before it went away are the interesting ones — but says so, + instead of letting a frozen view read as a quiet host. */} + {error != null && entries.length > 0 && ( +

+ {m.logs_stalled()} +

+ )} +
{visible.length === 0 ? ( -

{m.logs_empty()}

+ // An empty list has three quite different causes and used to render one sentence + // for all of them: the host is quiet, the request failed, or it hasn't answered yet. +
+ {error ? ( +
+

{m.common_error()}

+ {onRetry && ( + + )} +
+ ) : ( +

+ {isLoading ? m.common_loading() : m.logs_empty()} +

+ )} +
) : ( visible.map((e) => (
diff --git a/web/src/sections/Pairing/MoonlightPairingCard.tsx b/web/src/sections/Pairing/MoonlightPairingCard.tsx index b3964c77..f8c97e2d 100644 --- a/web/src/sections/Pairing/MoonlightPairingCard.tsx +++ b/web/src/sections/Pairing/MoonlightPairingCard.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Info, KeyRound } from "lucide-react"; import { type FC, useState } from "react"; +import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients"; import type { PairingStatus } from "@/api/gen/model/pairingStatus"; import { getGetPairingStatusQueryKey, @@ -22,16 +23,23 @@ export const MoonlightPairingSection: FC = () => { const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); const submit = useSubmitPairingPin(); - const onSubmit = () => + const onSubmit = () => { + // The mutation's success/error flags outlive the form: without this, starting a SECOND + // pairing attempt showed the previous one's "PIN sent" confirmation before a digit was typed. + submit.reset(); submit.mutate( { data: { pin } }, { onSuccess: () => { setPin(""); qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() }); + // The success message tells the operator to check the paired list, so refresh it — + // both planes, since this card's count spans them. + qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }); }, }, ); + }; return ( + + +
+

{m.store_job_lost()}

+

+ {m.store_job_lost_hint()} +

+
+ +
+ + ); + } return ; };