diff --git a/web/messages/de.json b/web/messages/de.json index 2a48a541..dac9ead7 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -66,6 +66,8 @@ "status_sessions_active": "{count} aktiv", "status_no_session": "Keine aktive Sitzung", "status_paired_count": "Gekoppelte Geräte", + "status_pin_waiting": "Wartet", + "status_pin_none": "Keine", "status_pin_pending": "Kopplungs-PIN ausstehend", "stream_codec": "Codec", "stream_resolution": "Auflösung", @@ -132,6 +134,7 @@ "display_state_lingering": "Wird gehalten", "display_state_pinned": "Angeheftet", "display_release_btn": "Freigeben", + "display_refresh_failed": "Aktualisierung vom Host fehlgeschlagen — es werden die zuletzt bekannten Einstellungen gezeigt. Deine Änderungen bleiben erhalten.", "display_release_all": "Alle gehaltenen freigeben", "display_expires_in": "Abbau in {sec}s", "display_sessions": "{count} streamend", @@ -139,6 +142,7 @@ "display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.", "display_arrange_save": "Anordnung speichern", "display_custom_desc": "Jede Option selbst festlegen.", + "display_preset_apply_named": "Voreinstellung {name} anwenden", "display_preset_current": "Aktiv", "display_preset_soon": "in Kürze", "display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.", @@ -301,6 +305,7 @@ "stats_kind_native": "Nativ", "stats_kind_gamestream": "GameStream", "stats_live_title": "Live", + "stats_live_window": "Es werden die letzten {count} Messpunkte gezeigt. Die gespeicherte Aufzeichnung enthält alles.", "stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.", "stats_latency_title": "Latenz nach Stufe", "stats_latency_axis": "µs", @@ -349,6 +354,7 @@ "store_from_source": "von", "store_search_placeholder": "Plugins durchsuchen…", "store_filter_all": "Alle Quellen", + "store_all_sources_failed": "Kein Katalog konnte geladen werden ({sources}). Der Store ist womöglich nicht leer — sieh im Tab „Quellen“ nach.", "store_empty": "Noch keine Plugins im Katalog.", "store_no_match": "Kein Plugin passt zur Suche.", "store_by_author": "von {author}", @@ -376,6 +382,7 @@ "store_installed_empty": "Noch keine Plugins installiert.", "store_running": "Läuft", "store_stopped": "Läuft nicht", + "store_version_unknown": "Version unbekannt", "store_uninstall": "Deinstallieren", "store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.", "store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.", @@ -510,6 +517,7 @@ "update_apply_wrong_password": "Falsches Passwort.", "update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.", "update_apply_working": "Starte…", + "update_apply_give_up": "Nicht weiter warten", "update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).", "update_applying_title": "Aktualisiere auf {version}", "update_stage_downloading": "Lade den Installer… {pct}%", diff --git a/web/messages/en.json b/web/messages/en.json index 942aac78..2bc48529 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -66,6 +66,8 @@ "status_sessions_active": "{count} active", "status_no_session": "No active session", "status_paired_count": "Paired clients", + "status_pin_waiting": "Waiting", + "status_pin_none": "None", "status_pin_pending": "Pairing PIN pending", "stream_codec": "Codec", "stream_resolution": "Resolution", @@ -132,6 +134,7 @@ "display_state_lingering": "Lingering", "display_state_pinned": "Pinned", "display_release_btn": "Release", + "display_refresh_failed": "Could not refresh from the host — showing the last known settings. Your edits are safe.", "display_release_all": "Release all kept", "display_expires_in": "tears down in {sec}s", "display_sessions": "{count} streaming", @@ -139,6 +142,7 @@ "display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.", "display_arrange_save": "Save arrangement", "display_custom_desc": "Set every option yourself.", + "display_preset_apply_named": "Apply preset {name}", "display_preset_current": "Active", "display_preset_soon": "coming soon", "display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.", @@ -301,6 +305,7 @@ "stats_kind_native": "Native", "stats_kind_gamestream": "GameStream", "stats_live_title": "Live", + "stats_live_window": "Showing the last {count} samples. The saved recording keeps everything.", "stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.", "stats_latency_title": "Latency by stage", "stats_latency_axis": "µs", @@ -349,6 +354,7 @@ "store_from_source": "from", "store_search_placeholder": "Search plugins…", "store_filter_all": "All sources", + "store_all_sources_failed": "Could not fetch any catalog ({sources}). The store may not be empty — check the Sources tab.", "store_empty": "No plugins in the catalog yet.", "store_no_match": "No plugin matches your search.", "store_by_author": "by {author}", @@ -376,6 +382,7 @@ "store_installed_empty": "No plugins installed yet.", "store_running": "Running", "store_stopped": "Not running", + "store_version_unknown": "version unknown", "store_uninstall": "Uninstall", "store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.", "store_uninstall_failed": "Could not start the removal.", @@ -510,6 +517,7 @@ "update_apply_wrong_password": "Wrong password.", "update_apply_throttled": "Too many attempts — wait a moment and try again.", "update_apply_working": "Starting…", + "update_apply_give_up": "Stop waiting", "update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).", "update_applying_title": "Updating to {version}", "update_stage_downloading": "Downloading the installer… {pct}%", diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index 585f1d0f..e7d08577 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -48,14 +48,30 @@ const tls = ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined; +// Half-configured TLS is the dangerous shape: one path set and the other missing silently drops to +// plain HTTP, and if PUNKTFUNK_UI_SECURE is also set the session cookie is marked Secure — which a +// browser then refuses to store over http://, so login appears to succeed and every next request is +// unauthenticated. Both failure modes are silent, so say something. +if (Boolean(certPath) !== Boolean(keyPath)) { + console.error( + `punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` + + "TLS needs BOTH. Serving plain HTTP.", + ); +} +if (!tls && /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "")) { + console.error( + "punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " + + "cookie will be marked Secure and dropped by the browser over http:// — login will not stick.", + ); +} + const server = Bun.serve({ port: process.env.NITRO_PORT || process.env.PORT || 3000, host: process.env.NITRO_HOST || process.env.HOST, // Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a // proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and // reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable. - idleTimeout: - Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120, + idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120, // `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1. tls, websocket: import.meta._websocket ? ws.websocket : undefined, diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index 570aea01..c7cac013 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -15,6 +15,7 @@ import { isPublicPath, type SessionData, sessionConfig, + sessionEpoch, uiPassword, } from "../util/auth"; @@ -64,7 +65,10 @@ export default defineEventHandler(async (event) => { } const session = await useSession(event, sessionConfig()); - if (session.data.authenticated) return; // authenticated — let it through + // The epoch check is what makes logout mean something: a cookie sealed before the last + // revocation unseals fine but no longer matches, so it is refused like any other bad session. + if (session.data.authenticated && session.data.epoch === sessionEpoch()) + return; // authenticated — let it through if (pathname.startsWith("/api")) { setResponseStatus(event, 401); diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index 26335b52..1b54ee49 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -13,6 +13,7 @@ import { peerAddress, type SessionData, sessionConfig, + sessionEpoch, timingSafeEqual, uiPassword, } from "../../util/auth"; @@ -53,6 +54,6 @@ export default defineEventHandler(async (event) => { } recordLoginSuccess(ip); const session = await useSession(event, sessionConfig()); - await session.update({ authenticated: true }); + await session.update({ authenticated: true, epoch: sessionEpoch() }); return { ok: true }; }); diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts index 1d242f27..54dc4a8e 100644 --- a/web/server/routes/_auth/logout.post.ts +++ b/web/server/routes/_auth/logout.post.ts @@ -1,9 +1,19 @@ -// POST /_auth/logout — clear the session cookie. +// POST /_auth/logout — clear the session cookie AND revoke every session issued so far. +// +// Clearing alone only deletes the browser's copy: the cookie is stateless, so a captured value +// stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means +// the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out +// everywhere" are the same action — which is the safer of the two to make the default. import { defineEventHandler, useSession } from "h3"; -import { type SessionData, sessionConfig } from "../../util/auth"; +import { + revokeAllSessions, + type SessionData, + sessionConfig, +} from "../../util/auth"; export default defineEventHandler(async (event) => { const session = await useSession(event, sessionConfig()); await session.clear(); + revokeAllSessions(); return { ok: true }; }); diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index d121c66f..c0c8bdb8 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -195,4 +195,30 @@ export function safeNextPath(next: string | undefined): string { export interface SessionData { authenticated?: boolean; + /** The epoch this session was sealed under — see `sessionEpoch`. */ + epoch?: number; +} + +/** + * A revocation counter for issued sessions. + * + * The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only + * deletes the BROWSER's copy. A cookie captured beforehand (a shared machine, a shell history, a + * TLS-inspecting proxy) stayed valid for its full 7-day TTL with nothing the operator could do + * about it — "log out" did not log anything out. + * + * Bumping this invalidates every previously issued cookie, because the gate compares the stamped + * epoch against the current one. It lives in memory, so a host restart also revokes — acceptable + * for a single-user console, and the safe direction to fail. + */ +let epoch = 1; + +/** The epoch a new session is stamped with, and the one the gate requires. */ +export function sessionEpoch(): number { + return epoch; +} + +/** Invalidate every session issued so far (the "sign out everywhere" lever). */ +export function revokeAllSessions(): void { + epoch += 1; } diff --git a/web/src/api/store.ts b/web/src/api/store.ts index 749078fa..e4a34fb4 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -81,7 +81,9 @@ export interface StoreCatalog { export interface InstalledPlugin { pkg: string; - version: string; + /** Nullable in the contract (`InstalledView.version`) — a CLI-installed plugin may carry no + * recorded version. Typed required here, the Installed tab rendered the literal "vundefined". */ + version?: string | null; tier: StoreTier; source?: string; entry_id?: string; @@ -226,6 +228,34 @@ export function useStoreRuntime() { const JOB_POLL_MS = 1_000; const JOB_MAX_FAILURES = 15; +/** + * The host's recent jobs, used to RE-ATTACH after a reload. + * + * The in-flight job id lived only in component state, so reloading the page (or opening the console + * on another device) lost all trace of a running install while the Install buttons stayed armed — + * and the host takes one job at a time, so the next click just bounced off a 409. The host keeps + * the list; ask it rather than remembering. + */ +export function useStoreJobs() { + return useQuery({ + queryKey: [...storeKeys.all, "jobs"] as const, + queryFn: () => apiFetch(`${BASE}/jobs`), + // Only needed to find an orphaned job on mount; the job query itself does the live polling. + staleTime: 5_000, + }); +} + +/** The newest job that is still running, if any — what a fresh page should re-attach to. */ +export function runningJob(jobs: StoreJob[] | undefined): StoreJob | undefined { + if (!jobs) return undefined; + // The list is oldest-first, so scan from the end for the most recent live one. + for (let i = jobs.length - 1; i >= 0; i--) { + const j = jobs[i]; + if (j?.state === "running") return j; + } + return undefined; +} + export function useStoreJob(id: string | null) { return useQuery({ queryKey: storeKeys.job(id ?? ""), diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 00000000..e905e370 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,45 @@ +import { getLocale } from "@/paraglide/runtime"; + +/** + * Date/time and number formatting that follows the CONSOLE's locale, not the browser's. + * + * A bare `toLocaleString()` uses `navigator.language`, so a console switched to German still + * rendered US-style timestamps (and vice versa) — the app said one thing and its dates another. + * `getLocale()` is Paraglide's resolved locale, which is what every string on screen uses. + * + * `Intl` formatters are expensive to construct and these run per table row, so they are cached + * per locale. + */ +const dateTimeCache = new Map(); + +function dateTimeFor(locale: string): Intl.DateTimeFormat { + let f = dateTimeCache.get(locale); + if (!f) { + f = new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + dateTimeCache.set(locale, f); + } + return f; +} + +/** Unix MILLISECONDS → a locale date-time, or an em dash for "never". */ +export function fmtDateTime(unixMs: number | undefined | null): string { + if (!unixMs) return "—"; + return dateTimeFor(getLocale()).format(new Date(unixMs)); +} + +/** Unix SECONDS → a locale date-time (the store's `fetched_at` convention). */ +export function fmtDateTimeSecs(unixSecs: number | undefined | null): string { + if (!unixSecs) return "—"; + return fmtDateTime(unixSecs * 1000); +} + +/** A number with the console locale's separators — never a hand-rolled `toFixed`. */ +export function fmtNumber(value: number, digits = 0): string { + return new Intl.NumberFormat(getLocale(), { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); +} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 7b969f50..53784e59 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -1,4 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; +import { useBlocker } from "@tanstack/react-router"; import { Button } from "@unom/ui/button"; import { toast } from "@unom/ui/toast"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; @@ -150,14 +151,17 @@ export const DisplaySection: FC = () => { if (seeded.current) setDraft(seeded.current); }; - // Last line of defence: a reload/close with pending edits loses them silently otherwise. The - // browser shows its own generic wording — the text is ignored, only returning a value counts. - useEffect(() => { - if (!dirty) return; - const warn = (e: BeforeUnloadEvent) => e.preventDefault(); - window.addEventListener("beforeunload", warn); - return () => window.removeEventListener("beforeunload", warn); - }, [dirty]); + // Don't lose pending edits, whichever way the operator leaves. + // + // This used to be a bare `beforeunload` listener, which only covers a reload or a tab close — + // clicking "Host" in the sidebar is a client-side route change the browser never hears about, + // so the draft vanished with no prompt at all. The router's blocker covers in-app navigation + // AND still arms `beforeunload` for the reload case, so it replaces the listener outright. + useBlocker({ + shouldBlockFn: () => !confirm(m.display_discard_confirm()), + enableBeforeUnload: () => dirty, + disabled: !dirty, + }); return (
@@ -174,17 +178,30 @@ export const DisplaySection: FC = () => {

{m.host_displays_help()}

+ {/* Once the form is on screen, a FAILED BACKGROUND POLL must not replace it — the + operator may be mid-edit, and swapping the card for an error box throws the + draft away to report a refetch we could simply retry. Only a failure with + nothing to show is worth the error state. */} + {draft && q.error && ( +

+ {m.display_refresh_failed()} +

+ )} {q.data && draft && ( void; presets: { id: string; summary: string; fields: EffectivePolicy }[]; customPresets: CustomPreset[]; + /** What the host reports as IN FORCE right now — not derived from the local draft. */ + serverEffective: EffectivePolicy; apply: (p: DisplayPolicy) => void; /** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */ applyAxis: (patch: Partial) => void; @@ -240,6 +259,7 @@ const DisplayForm: FC<{ setDraft, presets, customPresets, + serverEffective, apply, applyAxis, saveDraft, @@ -736,25 +756,29 @@ const DisplayForm: FC<{ onSet={(on) => applyAxis({ pnp_disable_monitors: on })} /> - {/* What's in force right now */} + {/* What's in force right now — read from the API's `effective`, not from the local draft. + Deriving it from the draft meant the row restated the operator's unsaved edits back to + them as though the host had already adopted them. */}
{m.display_effective()}: - {fmtKeepAlive(effective.keep_alive)} - {tr(TOPOLOGY_LABEL, effective.topology)} + {fmtKeepAlive(serverEffective.keep_alive)} + + + {tr(TOPOLOGY_LABEL, serverEffective.topology)} - {tr(CONFLICT_LABEL, effective.mode_conflict)} + {tr(CONFLICT_LABEL, serverEffective.mode_conflict)} - {tr(IDENTITY_LABEL, effective.identity)} + {tr(IDENTITY_LABEL, serverEffective.identity)} - {tr(LAYOUT_LABEL, effective.layout.mode)} + {tr(LAYOUT_LABEL, serverEffective.layout.mode)} - {`${effective.max_displays}×`} + {`${serverEffective.max_displays}×`} {(draft.game_session ?? "auto") === "dedicated" && ( {m.display_game_session_dedicated()} @@ -920,8 +944,13 @@ const CustomPresetCard: FC<{ tabIndex={busy ? -1 : 0} aria-pressed={selected} aria-disabled={busy || undefined} + aria-label={m.display_preset_apply_named({ name: preset.name })} onClick={() => !busy && onApply()} onKeyDown={(e) => { + // Only when the CARD itself has focus. Keydown bubbles, so Enter/Space on the + // rename/update/delete icons inside it also reached here and applied the preset + // instead of running the icon's action — a keyboard user could not delete a preset. + if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!busy) onApply(); diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index 4caa8190..0e1978ef 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -1,12 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@unom/ui/button"; +import { toast } from "@unom/ui/toast"; import { type FC, type ReactNode, useState } from "react"; +import { ApiError } from "@/api/fetcher"; +import type { UpdateStatus } from "@/api/gen/model"; import { getGetUpdateStatusQueryKey, useForceUpdateCheck, useGetUpdateStatus, } from "@/api/gen/update/update"; -import type { UpdateStatus } 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"; @@ -21,6 +23,8 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import { apiErrorMessage } from "@/lib/errors"; +import { fmtDateTimeSecs } from "@/lib/format"; import type { Loadable } from "@/lib/query"; import { m } from "@/paraglide/messages"; @@ -67,6 +71,14 @@ export const UpdateSection: FC = () => { onSuccess: (fresh) => { qc.setQueryData(getGetUpdateStatusQueryKey(), fresh); }, + // The host throttles repeat checks (429). Swallowing it made a second click within the + // window look like a dead button; say the check was skipped and why. + onError: (e) => + toast.error( + e instanceof ApiError && e.status === 429 + ? m.update_apply_throttled() + : (apiErrorMessage(e) ?? m.update_error()), + ), }); return ( @@ -75,9 +87,8 @@ export const UpdateSection: FC = () => { onCheck={checkNow} checkBusy={check.isPending} applying={applying} - onApplied={(target) => - setApplying({ target, startedAt: Date.now() }) - } + onApplied={(target) => setApplying({ target, startedAt: Date.now() })} + onGiveUp={() => setApplying(null)} /> ); }; @@ -88,11 +99,18 @@ export const UpdateCard: FC<{ checkBusy: boolean; applying: { target: string; startedAt: number } | null; onApplied: (target: string) => void; -}> = ({ state, onCheck, checkBusy, applying, onApplied }) => { + /** Leave the applying state after a timeout — otherwise the card waits forever. */ + onGiveUp: () => void; +}> = ({ state, onCheck, checkBusy, applying, onApplied, onGiveUp }) => { const s = state.data; const inFlight = Boolean(applying) || Boolean(s?.job); const timedOut = applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS; + // Is the snapshot we are rendering still being refreshed? While the host is gone the query + // keeps its LAST payload — including a `job` that was in progress when it went away — so the + // timeout warning, gated on `!job`, could never fire in the one case it exists for. A failing + // poll means the job field describes a host we can no longer see. + const snapshotStale = Boolean(state.error); return ( @@ -156,6 +174,8 @@ export const UpdateCard: FC<{ status={s} reconnecting={Boolean(state.error)} timedOut={timedOut} + snapshotStale={snapshotStale} + onGiveUp={onGiveUp} /> ) : s.available ? ( s.apply === "full" || s.apply === "staged" ? ( @@ -214,7 +234,7 @@ export const UpdateCard: FC<{ {s.last_checked_unix != null && ( {m.update_last_checked()}{" "} - {new Date(s.last_checked_unix * 1000).toLocaleString()} + {fmtDateTimeSecs(s.last_checked_unix)} )}
@@ -356,7 +376,10 @@ const ApplyProgress: FC<{ status: UpdateStatus; reconnecting: boolean; timedOut: boolean; -}> = ({ status, reconnecting, timedOut }) => { + /** The rendered snapshot can no longer be refreshed — its `job` may describe a vanished host. */ + snapshotStale: boolean; + onGiveUp: () => void; +}> = ({ status, reconnecting, timedOut, snapshotStale, onGiveUp }) => { const job = status.job; const pct = job?.total_bytes && job.total_bytes > 0 @@ -396,12 +419,18 @@ const ApplyProgress: FC<{ />
)} - {/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out" — - the warning is for the host being GONE longer than a restart explains. */} - {timedOut && !job && ( -

- {m.update_apply_timeout()} -

+ {/* A job we can still SEE progressing (e.g. the Deck's tens-of-minutes source rebuild) is + not "timed out" — the warning is for the host being GONE longer than a restart + explains. A stale snapshot's job does not count as seeing one. */} + {timedOut && (!job || snapshotStale) && ( +
+

{m.update_apply_timeout()}

+ {/* Without this the card sits in "applying" forever and the operator cannot even + re-check — the state was only ever cleared by a status that never arrives. */} + +
)} ); diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index a9706f8b..4828eee5 100644 --- a/web/src/sections/Stats/LiveCard.tsx +++ b/web/src/sections/Stats/LiveCard.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { type FC, useMemo } from "react"; import { ApiError } from "@/api/fetcher"; import type { Capture } from "@/api/gen/model/capture"; import { @@ -27,9 +27,27 @@ export const LiveSection: FC = () => { return ; }; +/** + * How many samples the live charts plot. + * + * The live endpoint returns the capture SO FAR, which grows without bound — a capture left running + * over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail + * is also the only part anyone watches live (the full series is what the saved recording is for), + * so plot a bounded window and leave the rest to the detail view. + */ +const LIVE_WINDOW = 600; + /** Live graphs while a capture is armed: latency stack + throughput. */ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { - const samples = live.data?.samples ?? []; + const all = live.data?.samples; + // Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so + // an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart + // rebuild either (the charts memoise on exactly this). + const samples = useMemo( + () => + all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []), + [all], + ); // A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as // "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting". const error = @@ -58,6 +76,11 @@ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { + {(live.data?.samples?.length ?? 0) > LIVE_WINDOW && ( +

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

+ )} )} diff --git a/web/src/sections/Stats/charts.tsx b/web/src/sections/Stats/charts.tsx index 7f840ce4..c9179b15 100644 --- a/web/src/sections/Stats/charts.tsx +++ b/web/src/sections/Stats/charts.tsx @@ -4,7 +4,7 @@ // otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample // carries — native (queue/capture/submit/encode/send) and gamestream // (capture/encode/packetize/send) both stack sensibly. -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useMemo, useState } from "react"; import { Area, AreaChart, @@ -85,6 +85,55 @@ function colorFor(name: string, i: number): string { return STAGE_COLORS[name] ?? PALETTE[i % PALETTE.length] ?? "#6c5bf3"; } +/** + * Shared X-axis config for every chart here. + * + * `type="number"` + an explicit domain, NOT recharts' default category axis. As a category axis + * every sample is one evenly-spaced slot, so a capture that idled for two minutes drew that gap as + * a single step and the timeline was a lie — precisely the thing you are reading these charts to + * find. As a number axis the spacing is the actual elapsed time. + */ +const timeAxis = { + dataKey: "t", + type: "number", + domain: ["dataMin", "dataMax"], + scale: "time", + tick: axisTick, + stroke: gridStroke, + unit: "s", + allowDecimals: false, +} as const; + +/** + * Split a capture at every session boundary and insert a gap between the pieces. + * + * A capture can span more than one session (`StatsSample.session_id`), and joining those samples + * into one continuous line implies a continuity that never existed — the stream stopped and a + * different client started a new one. Recharts breaks a line wherever a value is `null`, so one + * spacer row between sessions renders the discontinuity without any per-chart special-casing. + */ +function withSessionBreaks( + samples: StatsSample[], + rows: T[], +): (T | { t: number })[] { + const out: (T | { t: number })[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row) continue; + const prev = samples[i - 1]; + const cur = samples[i]; + if (prev && cur && prev.session_id !== cur.session_id) { + // A bare `t` row: every series key is absent ⇒ null ⇒ recharts lifts the pen. + out.push({ t: row.t - 0.001 }); + } + out.push(row); + } + return out; +} + +/** Seconds since the capture began, as a number (see `timeAxis`). */ +const tSeconds = (s: StatsSample): number => s.t_ms / 1000; + /** Latency stacked-area (µs) — the "where does the time go" view. With `toggle`, a * p50/p99 switch flips every stage band between its median and tail. */ export function LatencyChart({ @@ -95,24 +144,42 @@ export function LatencyChart({ toggle?: boolean; }) { const [p99, setP99] = useState(false); - const names = stageNames(samples); - const rows = samples.map((s) => { - const row: Record = { t: Math.round(s.t_ms / 1000) }; - const byName = new Map(s.stages.map((st) => [st.name, st] as const)); - for (const n of names) { - const st = byName.get(n); - row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0; - } - return row; - }); + const names = useMemo(() => stageNames(samples), [samples]); + // Memoised: this walks every sample × every stage, and the live card re-renders it on a 2 s + // poll. Without this the whole series was rebuilt on every unrelated render too. + const rows = useMemo(() => { + const built = samples.map((s) => { + // `t` is declared on the type so the row satisfies `withSessionBreaks`' constraint; + // the stage columns are added by name below. + const row: Record & { t: number } = { t: tSeconds(s) }; + const byName = new Map(s.stages.map((st) => [st.name, st] as const)); + for (const n of names) { + const st = byName.get(n); + row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0; + } + return row; + }); + return withSessionBreaks(samples, built); + }, [samples, names, p99]); return (
{toggle && ( -
- + // The button used to be labelled with the percentile currently PLOTTED while looking + // like an action, so it read as "click to show p99" when p99 was already showing. + // Two explicit options, with the active one pressed, says which is which. +
+ {([false, true] as const).map((wantP99) => ( + + ))}
)} @@ -121,7 +188,7 @@ export function LatencyChart({ margin={{ top: 6, right: 8, left: 0, bottom: 0 }} > - + ({ - t: Math.round(s.t_ms / 1000), - fps: s.fps, - repeat: s.repeat_fps, - mbps: s.mbps, - // The configured encoder target (kbps → Mb/s) so goodput can be read against it. - target: s.bitrate_kbps / 1000, - })); + const rows = useMemo( + () => + withSessionBreaks( + samples, + samples.map((s) => ({ + t: tSeconds(s), + fps: s.fps, + repeat: s.repeat_fps, + mbps: s.mbps, + // The configured encoder target (kbps → Mb/s) so goodput reads against it. + target: s.bitrate_kbps / 1000, + })), + ), + [samples], + ); return ( - + ({ - t: Math.round(s.t_ms / 1000), - frames: s.frames_dropped, - packets: s.packets_dropped, - send: s.send_dropped, - fec: s.fec_recovered, - })); + const rows = useMemo( + () => + withSessionBreaks( + samples, + samples.map((s) => ({ + t: tSeconds(s), + frames: s.frames_dropped, + packets: s.packets_dropped, + send: s.send_dropped, + fec: s.fec_recovered, + })), + ), + [samples], + ); return ( <> {kind === "gamestream" && ( @@ -253,7 +334,7 @@ export function HealthChart({ margin={{ top: 6, right: 8, left: 0, bottom: 0 }} > - + void; }> = ({ onInstall, onInstallSpec }) => { const catalog = useStoreCatalog(); + // Sources that could not be fetched — the difference between "this host has no plugins" and + // "the console could not find out". + const failedSources = (catalog.data?.sources ?? []).filter( + (src) => src.error || src.stale, + ); const [query, setQuery] = useState(""); const [source, setSource] = useState(null); @@ -97,7 +102,16 @@ export const BrowseTab: FC<{ flush className="p-8 text-center text-sm text-muted-foreground" > - {entries.length === 0 ? m.store_empty() : m.store_no_match()} + {entries.length > 0 + ? m.store_no_match() + : failedSources.length > 0 + ? // An all-sources-failed catalog is a SUCCESSFUL request that happens to + // carry nothing, so "no plugins available" was the console reporting a + // broken fetch as an empty store. Name the sources that failed. + m.store_all_sources_failed({ + sources: failedSources.map((f) => f.name).join(", "), + }) + : m.store_empty()} ) : ( diff --git a/web/src/sections/Store/Installed.tsx b/web/src/sections/Store/Installed.tsx index b03b39bc..b5178c1d 100644 --- a/web/src/sections/Store/Installed.tsx +++ b/web/src/sections/Store/Installed.tsx @@ -89,7 +89,7 @@ export const InstalledList: FC<{
- v{p.version} + {p.version ? `v${p.version}` : m.store_version_unknown()} diff --git a/web/src/sections/Store/Sources.tsx b/web/src/sections/Store/Sources.tsx index 0509e1e0..bff01c68 100644 --- a/web/src/sections/Store/Sources.tsx +++ b/web/src/sections/Store/Sources.tsx @@ -31,15 +31,17 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { fmtDateTimeSecs } from "@/lib/format"; import { m } from "@/paraglide/messages"; /** A source the operator has filled in but not yet agreed to trust. The console password is NOT * part of the draft — it is collected by the trust dialog, at the moment the decision is made. */ type SourceDraft = Omit & { name: string }; -/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */ +/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. + * Locale-aware via lib/format.ts — `toLocaleString` follows the browser, not the console. */ const fmtFetched = (secs: number): string => - secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never(); + secs > 0 ? fmtDateTimeSecs(secs) : m.store_source_never(); /** * Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove. diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx index 532e8a81..7570895a 100644 --- a/web/src/sections/Store/index.tsx +++ b/web/src/sections/Store/index.tsx @@ -1,13 +1,15 @@ import Section from "@unom/ui/section"; import { toast } from "@unom/ui/toast"; -import { type FC, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { ApiError } from "@/api/fetcher"; import { type InstallBody, type InstalledPlugin, + runningJob, type StoreEntry, useInstallPlugin, useStoreCatalog, + useStoreJobs, useUninstallPlugin, } from "@/api/store"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -39,6 +41,14 @@ export const SectionStore: FC = () => { const [jobId, setJobId] = useState(null); const catalog = useStoreCatalog(); + // Re-attach to a job that was already running when this page loaded — an install survives a + // reload on the host side, and losing sight of it left the Install buttons armed against a host + // that answers 409. + const jobs = useStoreJobs(); + const orphan = runningJob(jobs.data); + useEffect(() => { + if (orphan && !jobId) setJobId(orphan.id); + }, [orphan, jobId]); const install = useInstallPlugin(); const uninstall = useUninstallPlugin();