fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SessionData>(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);
|
||||
|
||||
@@ -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<SessionData>(event, sessionConfig());
|
||||
await session.update({ authenticated: true });
|
||||
await session.update({ authenticated: true, epoch: sessionEpoch() });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -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<SessionData>(event, sessionConfig());
|
||||
await session.clear();
|
||||
revokeAllSessions();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+31
-1
@@ -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<StoreJob[]>(`${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 ?? ""),
|
||||
|
||||
@@ -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<string, Intl.DateTimeFormat>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-card">
|
||||
@@ -174,17 +178,30 @@ export const DisplaySection: FC = () => {
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.host_displays_help()}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
<QueryState
|
||||
isLoading={q.isLoading}
|
||||
error={q.error}
|
||||
error={draft ? undefined : q.error}
|
||||
refetch={q.refetch}
|
||||
>
|
||||
{draft && q.error && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
{m.display_refresh_failed()}
|
||||
</p>
|
||||
)}
|
||||
{q.data && draft && (
|
||||
<DisplayForm
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
presets={q.data.presets}
|
||||
customPresets={q.data.custom_presets}
|
||||
serverEffective={q.data.effective}
|
||||
apply={apply}
|
||||
applyAxis={applyAxis}
|
||||
saveDraft={saveDraft}
|
||||
@@ -224,6 +241,8 @@ const DisplayForm: FC<{
|
||||
setDraft: (p: DisplayPolicy) => 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<DisplayPolicy>) => 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. */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.display_effective()}:
|
||||
</span>
|
||||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{tr(TOPOLOGY_LABEL, effective.topology)}
|
||||
{fmtKeepAlive(serverEffective.keep_alive)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{tr(TOPOLOGY_LABEL, serverEffective.topology)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(CONFLICT_LABEL, effective.mode_conflict)}
|
||||
{tr(CONFLICT_LABEL, serverEffective.mode_conflict)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(IDENTITY_LABEL, effective.identity)}
|
||||
{tr(IDENTITY_LABEL, serverEffective.identity)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(LAYOUT_LABEL, effective.layout.mode)}
|
||||
{tr(LAYOUT_LABEL, serverEffective.layout.mode)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||
<Badge variant="outline">{`${serverEffective.max_displays}×`}</Badge>
|
||||
{(draft.game_session ?? "auto") === "dedicated" && (
|
||||
<Badge variant="secondary">
|
||||
{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();
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
@@ -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 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.update_last_checked()}{" "}
|
||||
{new Date(s.last_checked_unix * 1000).toLocaleString()}
|
||||
{fmtDateTimeSecs(s.last_checked_unix)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<{
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_apply_timeout()}
|
||||
</p>
|
||||
{/* 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) && (
|
||||
<div className="space-y-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<p>{m.update_apply_timeout()}</p>
|
||||
{/* 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. */}
|
||||
<Button variant="outline" size="sm" onClick={onGiveUp}>
|
||||
{m.update_apply_give_up()}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <LiveCard live={live} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<Capture> }> = ({ 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<Capture> }> = ({ live }) => {
|
||||
<ChartBlock title={m.stats_throughput_title()}>
|
||||
<ThroughputChart samples={samples} />
|
||||
</ChartBlock>
|
||||
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.stats_live_window({ count: LIVE_WINDOW })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -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<T extends { t: number }>(
|
||||
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<string, number> = { 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<string, number> & { 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 (
|
||||
<div className="space-y-2">
|
||||
{toggle && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setP99((v) => !v)}>
|
||||
{p99 ? m.stats_p99() : m.stats_p50()}
|
||||
</Button>
|
||||
// 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.
|
||||
<div className="flex justify-end gap-1">
|
||||
{([false, true] as const).map((wantP99) => (
|
||||
<Button
|
||||
key={String(wantP99)}
|
||||
variant={p99 === wantP99 ? "default" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={p99 === wantP99}
|
||||
onClick={() => setP99(wantP99)}
|
||||
>
|
||||
{wantP99 ? m.stats_p99() : m.stats_p50()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChartFrame>
|
||||
@@ -121,7 +188,7 @@ export function LatencyChart({
|
||||
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
@@ -151,19 +218,26 @@ export function LatencyChart({
|
||||
|
||||
/** New vs repeat fps (left axis) + tx goodput Mb/s vs the configured target (right axis). */
|
||||
export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
|
||||
const rows = samples.map((s) => ({
|
||||
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 (
|
||||
<ChartFrame>
|
||||
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
yAxisId="fps"
|
||||
tick={axisTick}
|
||||
@@ -233,13 +307,20 @@ export function HealthChart({
|
||||
samples: StatsSample[];
|
||||
kind?: string;
|
||||
}) {
|
||||
const rows = samples.map((s) => ({
|
||||
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 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<XAxis {...timeAxis} />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** ms → `m:ss`. */
|
||||
@@ -7,9 +8,9 @@ export function fmtDuration(ms: number): string {
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Locale-aware (see lib/format.ts) — a bare `toLocaleString` follows the BROWSER, not the app. */
|
||||
export function fmtTimestamp(unixMs: number): string {
|
||||
if (!unixMs) return "—";
|
||||
return new Date(unixMs).toLocaleString();
|
||||
return fmtDateTime(unixMs);
|
||||
}
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
|
||||
@@ -31,6 +31,11 @@ export const BrowseTab: FC<{
|
||||
onInstallSpec: () => 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<string | null>(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()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -89,7 +89,7 @@ export const InstalledList: FC<{
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
|
||||
v{p.version}
|
||||
{p.version ? `v${p.version}` : m.store_version_unknown()}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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<SourceBody, "password"> & { 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.
|
||||
|
||||
@@ -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<string | null>(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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user