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 ; };