feat(web): the console follows the host's events instead of asking ten times a minute
The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
|
||||
* Same vocabulary as the SSE `?kinds=` filter, so the two stay learnable together. */
|
||||
export const EVENT_KINDS = [
|
||||
"client.*",
|
||||
"client.connected",
|
||||
"client.disconnected",
|
||||
"session.*",
|
||||
"session.started",
|
||||
"session.ended",
|
||||
"stream.*",
|
||||
"stream.started",
|
||||
"stream.stopped",
|
||||
"game.*",
|
||||
"game.running",
|
||||
"game.exited",
|
||||
"pairing.*",
|
||||
"pairing.pending",
|
||||
"pairing.completed",
|
||||
"pairing.denied",
|
||||
"display.*",
|
||||
"display.created",
|
||||
"display.released",
|
||||
"library.changed",
|
||||
"update.available",
|
||||
"update.applied",
|
||||
"host.started",
|
||||
"host.stopping",
|
||||
] as const;
|
||||
|
||||
const EMPTY: HookEntry = { on: "session.started", run: "" };
|
||||
|
||||
/**
|
||||
* Add or edit one hook.
|
||||
*
|
||||
* A hook is either a shell command or a webhook — never both in this form, because "run this AND
|
||||
* post that" is two hooks and pretending otherwise makes the failure modes impossible to reason
|
||||
* about. The action kind is therefore a choice, not two optional fields.
|
||||
*/
|
||||
export const HookForm: FC<{
|
||||
/** The hook being edited, `EMPTY`-seeded for a new one, or null when closed. */
|
||||
value: HookEntry | null;
|
||||
onCancel: () => void;
|
||||
onSave: (hook: HookEntry) => void;
|
||||
}> = ({ value, onCancel, onSave }) => {
|
||||
const [draft, setDraft] = useState<HookEntry>(EMPTY);
|
||||
const [kind, setKind] = useState<"run" | "webhook">("run");
|
||||
const [filtered, setFiltered] = useState(false);
|
||||
|
||||
// Re-seed whenever a different hook is opened (the dialog stays mounted between edits).
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
setDraft(value);
|
||||
setKind(value.webhook ? "webhook" : "run");
|
||||
setFiltered(!!value.filter);
|
||||
}, [value]);
|
||||
|
||||
const set = (patch: Partial<HookEntry>) =>
|
||||
setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const action = kind === "run" ? (draft.run ?? "") : (draft.webhook ?? "");
|
||||
const ready = draft.on.trim().length > 0 && action.trim().length > 0;
|
||||
|
||||
const commit = () => {
|
||||
// Emit exactly one action field, and drop an unticked filter entirely — leaving `{}` behind
|
||||
// would read as "filter on nothing" to anyone reading the config file later.
|
||||
const out: HookEntry = {
|
||||
on: draft.on.trim(),
|
||||
...(kind === "run"
|
||||
? { run: action.trim(), webhook: null }
|
||||
: { webhook: action.trim(), run: null }),
|
||||
...(filtered && draft.filter ? { filter: draft.filter } : {}),
|
||||
...(draft.debounce_ms ? { debounce_ms: draft.debounce_ms } : {}),
|
||||
...(draft.timeout_s ? { timeout_s: draft.timeout_s } : {}),
|
||||
...(kind === "webhook" && draft.hmac_secret_file
|
||||
? { hmac_secret_file: draft.hmac_secret_file }
|
||||
: {}),
|
||||
};
|
||||
onSave(out);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={value !== null} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent className="max-h-[85vh] max-w-xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{m.automation_hook_title()}</DialogTitle>
|
||||
<DialogDescription>{m.automation_hook_help()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
|
||||
<select
|
||||
id="hook-on"
|
||||
value={draft.on}
|
||||
onChange={(e) => set({ on: e.target.value })}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_on_help()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{m.automation_field_action()}
|
||||
</legend>
|
||||
<div className="flex gap-2">
|
||||
{(["run", "webhook"] as const).map((k) => (
|
||||
<Button
|
||||
key={k}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={kind === k ? "default" : "outline"}
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => setKind(k)}
|
||||
>
|
||||
{k === "run"
|
||||
? m.automation_action_run()
|
||||
: m.automation_action_webhook()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="hook-action"
|
||||
aria-label={m.automation_field_action()}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={action}
|
||||
placeholder={
|
||||
kind === "run" ? "/usr/local/bin/on-stream.sh" : "https://…"
|
||||
}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
kind === "run"
|
||||
? { run: e.target.value }
|
||||
: { webhook: e.target.value },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind === "run"
|
||||
? m.automation_action_run_help()
|
||||
: m.automation_action_webhook_help()}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{kind === "webhook" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-hmac">{m.automation_field_hmac()}</Label>
|
||||
<Input
|
||||
id="hook-hmac"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.hmac_secret_file ?? ""}
|
||||
onChange={(e) => set({ hmac_secret_file: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_hmac_help()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Label className="flex items-start gap-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={filtered}
|
||||
onCheckedChange={(n) => setFiltered(n === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{m.automation_field_filter()}</span>
|
||||
</Label>
|
||||
|
||||
{filtered && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-client">
|
||||
{m.automation_filter_client()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-client"
|
||||
value={draft.filter?.client ?? ""}
|
||||
onChange={(e) =>
|
||||
set({ filter: { ...draft.filter, client: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-app">{m.automation_filter_app()}</Label>
|
||||
<Input
|
||||
id="hook-app"
|
||||
value={draft.filter?.app ?? ""}
|
||||
onChange={(e) =>
|
||||
set({ filter: { ...draft.filter, app: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-debounce">
|
||||
{m.automation_field_debounce()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-debounce"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.debounce_ms ?? 0}
|
||||
onChange={(e) =>
|
||||
set({ debounce_ms: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{kind === "run" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-timeout">
|
||||
{m.automation_field_timeout()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={draft.timeout_s ?? 30}
|
||||
onChange={(e) =>
|
||||
set({ timeout_s: Number(e.target.value) || 30 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button disabled={!ready} onClick={commit}>
|
||||
{m.automation_hook_save()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Pencil, Plus, Terminal, Trash2, Webhook } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import { useGetHooks } from "@/api/gen/hooks/hooks";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { HookForm } from "./HookForm";
|
||||
|
||||
/**
|
||||
* **Automation** — the operator's event hooks (`GET/PUT /api/v1/hooks`).
|
||||
*
|
||||
* The host has run these since the API existed and the console never showed them: the only way to
|
||||
* see or change what your machine does when a stream starts was to edit the config file by hand.
|
||||
*
|
||||
* The whole list is written in one PUT (the host has no per-hook route), so this edits a local copy
|
||||
* and saves explicitly — no auto-save. That is deliberate for a screen whose contents are shell
|
||||
* commands: a half-typed command should never reach the host because a poll landed.
|
||||
*/
|
||||
export const SectionAutomation: FC = () => {
|
||||
useLocale();
|
||||
const query = useGetHooks();
|
||||
const save = useSaveHooks();
|
||||
|
||||
const [hooks, setHooks] = useState<HookEntry[] | null>(null);
|
||||
const [editing, setEditing] = useState<{
|
||||
index: number;
|
||||
hook: HookEntry;
|
||||
} | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [wrongPassword, setWrongPassword] = useState(false);
|
||||
|
||||
// Seed once. Unlike the display card there is no re-seed-when-clean dance: nothing else in the
|
||||
// console writes hooks, so the server value cannot move underneath an edit.
|
||||
const server = query.data?.hooks;
|
||||
useEffect(() => {
|
||||
if (hooks === null && server) setHooks(server);
|
||||
}, [server, hooks]);
|
||||
|
||||
const list = hooks ?? [];
|
||||
const dirty =
|
||||
hooks !== null && JSON.stringify(hooks) !== JSON.stringify(server ?? []);
|
||||
|
||||
const upsert = (hook: HookEntry) => {
|
||||
if (!editing) return;
|
||||
setHooks((prev) => {
|
||||
const next = [...(prev ?? [])];
|
||||
if (editing.index < 0) next.push(hook);
|
||||
else next[editing.index] = hook;
|
||||
return next;
|
||||
});
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
if (!confirm(m.automation_delete_confirm())) return;
|
||||
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
setWrongPassword(false);
|
||||
try {
|
||||
await save.mutateAsync({ hooks: list, password });
|
||||
setConfirming(false);
|
||||
setPassword("");
|
||||
toast.success(m.automation_saved());
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
toast.error(m.automation_save_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.automation_title()}</h1>
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.automation_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>{m.automation_hooks_title()}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
index: -1,
|
||||
hook: { on: "session.started", run: "" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{m.automation_add()}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<QueryState
|
||||
isLoading={query.isLoading}
|
||||
error={query.error}
|
||||
refetch={query.refetch}
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.automation_empty()}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((h, i) => (
|
||||
<li
|
||||
// The list is operator-ordered and has no ids; the index IS the identity
|
||||
// here, and rows only move when the operator moves them.
|
||||
key={`${h.on}:${hookAction(h)}:${i}`}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{h.webhook ? (
|
||||
<Webhook className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Terminal className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{h.on}</Badge>
|
||||
{hookFilterSummary(h) && (
|
||||
<Badge variant="outline">
|
||||
{hookFilterSummary(h)}
|
||||
</Badge>
|
||||
)}
|
||||
{!!h.debounce_ms && (
|
||||
<Badge variant="outline">
|
||||
{m.automation_debounce_badge({
|
||||
ms: h.debounce_ms,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{hookAction(h)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.automation_edit()}
|
||||
onClick={() => setEditing({ index: i, hook: h })}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.automation_delete()}
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
{dirty && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md bg-[var(--warning)]/10 px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{m.automation_unsaved()}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setHooks(server ?? [])}
|
||||
>
|
||||
{m.display_revert()}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setConfirming(true)}>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<HookForm
|
||||
value={editing?.hook ?? null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={upsert}
|
||||
/>
|
||||
|
||||
{/* Saving installs commands the host will run on its own — same bar as an update or an
|
||||
unreviewed install, so the same password. */}
|
||||
<Dialog
|
||||
open={confirming}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setConfirming(false);
|
||||
setWrongPassword(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{m.automation_confirm_title()}</DialogTitle>
|
||||
<DialogDescription>{m.automation_confirm_body()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="automation-password">
|
||||
{m.store_spec_password()}
|
||||
</Label>
|
||||
<Input
|
||||
id="automation-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{wrongPassword && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{m.update_apply_wrong_password()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
setWrongPassword(false);
|
||||
}}
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={save.isPending || password.length === 0}
|
||||
onClick={commit}
|
||||
>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -15,8 +15,18 @@ import { DashboardView } from "./view";
|
||||
export const SectionDashboard: FC = () => {
|
||||
useLocale();
|
||||
const qc = useQueryClient();
|
||||
// Poll live status every 2s so the console tracks an active session.
|
||||
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
|
||||
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
|
||||
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
|
||||
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
|
||||
// safety net in case the stream is unavailable.
|
||||
const status = useGetStatus({
|
||||
query: {
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.video_streaming || (q.state.data?.games?.length ?? 0) > 0
|
||||
? 2_000
|
||||
: 15_000,
|
||||
},
|
||||
});
|
||||
// The catalog, for the running-game card's box art. Fetched once and held: a library scan touches
|
||||
// every installed store's on-disk metadata, so it must not ride the 2 s status poll.
|
||||
const library = useGetLibrary(undefined, {
|
||||
|
||||
@@ -956,7 +956,17 @@ const CustomPresetCard: FC<{
|
||||
*/
|
||||
const LiveDisplays: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
|
||||
// Create/release arrive on the event stream (api/events.ts), so the timer is only here for the
|
||||
// one thing events cannot express: the per-second "tears down in Ns" countdown on a lingering
|
||||
// display. With nothing lingering it drops to a slow safety net.
|
||||
const state = useGetDisplayState({
|
||||
query: {
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.displays?.some((d) => d.expires_in_ms != null)
|
||||
? 2_000
|
||||
: 15_000,
|
||||
},
|
||||
});
|
||||
const release = useReleaseDisplay();
|
||||
const displays = state.data?.displays ?? [];
|
||||
const kept = displays.filter((d) => d.state !== "active");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useGetLocalSummary } from "@/api/gen/host/host";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* "Something else is already listening on these ports."
|
||||
*
|
||||
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) running on the same
|
||||
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
|
||||
* even though it is the single most common reason a punktfunk host looks installed and working but
|
||||
* no client can reach it — two servers fighting over the same ports, with whichever won the bind
|
||||
* answering the client.
|
||||
*
|
||||
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
|
||||
*/
|
||||
export const ConflictsCard: FC = () => {
|
||||
// Static per host boot (the host probes once at startup), so there is nothing to poll for.
|
||||
const summary = useGetLocalSummary({ query: { staleTime: 5 * 60_000 } });
|
||||
const conflicts = summary.data?.conflicts ?? [];
|
||||
if (conflicts.length === 0) return null;
|
||||
return (
|
||||
<Card className="border-amber-600/40 dark:border-amber-500/40">
|
||||
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
|
||||
{m.host_conflicts_title()}
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.host_conflicts_help()}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{conflicts.map((c) => (
|
||||
<li
|
||||
key={c}
|
||||
className="rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,9 @@ import { m } from "@/paraglide/messages";
|
||||
*/
|
||||
export const GpuSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const gpus = useListGpus({ query: { refetchInterval: 5_000 } });
|
||||
// GPU state only moves when a session starts or ends, which the event stream reports — so this
|
||||
// is a slow safety net rather than a 5 s poll of a device enumeration.
|
||||
const gpus = useListGpus({ query: { refetchInterval: 20_000 } });
|
||||
const setPref = useSetGpuPreference();
|
||||
|
||||
const apply = (mode: "auto" | "manual", gpuId?: string) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { ConflictsCard } from "./ConflictsCard";
|
||||
import { GpuSection } from "./GpuCard";
|
||||
import { UpdateSection } from "./UpdateCard";
|
||||
import { HostView } from "./view";
|
||||
@@ -14,6 +15,7 @@ export const SectionHost: FC = () => {
|
||||
<HostView
|
||||
host={host}
|
||||
compositors={compositors}
|
||||
conflicts={<ConflictsCard />}
|
||||
gpu={<GpuSection />}
|
||||
update={<UpdateSection />}
|
||||
/>
|
||||
|
||||
@@ -16,13 +16,18 @@ export const HostView: FC<{
|
||||
gpu?: ReactNode;
|
||||
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
|
||||
update?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, update }) => {
|
||||
/** Warning about other Moonlight-compatible servers on this machine — renders nothing when
|
||||
* there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */
|
||||
conflicts?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, update, conflicts }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||
|
||||
{conflicts}
|
||||
|
||||
<QueryState
|
||||
isLoading={host.isLoading}
|
||||
error={host.error}
|
||||
|
||||
@@ -24,7 +24,10 @@ import { m } from "@/paraglide/messages";
|
||||
*/
|
||||
export const PendingDevicesSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
|
||||
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
|
||||
// the rows carry an age that should not visibly lag.
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
|
||||
const approve = useApprovePendingDevice();
|
||||
const deny = useDenyPendingDevice();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user