Files
punktfunk/web/src/sections/Automation/index.tsx
T
enricobuehlerandClaude Opus 5 4a5d4b0a71 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>
2026-08-01 00:20:10 +02:00

271 lines
8.1 KiB
TypeScript

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