feat(mgmt/web): show the running game, and who decides when it ends

The host knows what it launched and, after a disconnect, that it is counting
down to closing it. None of that was visible: the console showed a stream with
no game, and a game on its way to being closed was something you found out about
afterwards.

The Dashboard gains a running-game card above the session card — box art matched
against the catalog it already fetches, so no new endpoint and no request on the
2 s status poll. "End now" means the two different things the row's state
implies: a live game ends by stopping its session (what then happens to the game
follows the policy — stopping a session is not licence to close a game), while
one already waiting out its reconnect window has no session left to stop and is
ended directly.

The settings card sits next to the display keep-alive policy because that is the
same question one step out: keep-alive decides how long a *display* outlives a
disconnect, this decides whether the *game* does. The copy says plainly what
`always` costs, that a drop is not someone pressing Stop, and that a display kept
forever is unaffected either way — the precedence rule that would otherwise
surprise someone. Where a build enforces nothing (macOS, no launch path) the
controls are shown disabled rather than hidden: "does nothing here" is
information.

Also fixes the story fixtures, which had gone stale against the `games[]` field
Phase 1 added, and adds a story for the state the card exists for — a game whose
client walked away.

web: build + tsc clean, biome-formatted; en/de messages complete.
This commit is contained in:
2026-07-26 18:28:13 +02:00
parent 110eabf281
commit 6a2c127ce9
9 changed files with 495 additions and 5 deletions
+138
View File
@@ -0,0 +1,138 @@
import { Gamepad2, XCircle } from "lucide-react";
import type { FC } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* What the host is running, and — when a client has vanished — how long its game has left.
*
* The `grace` rows are the reason this card exists at all. A game whose session is gone is on a
* countdown to being closed, which costs unsaved progress; that has to be visible somewhere, with a
* way to say "yes, do it now" or (by reconnecting) "no". The live rows come almost free alongside.
*/
export const RunningGames: FC<{
games: ActiveGame[];
/** The catalog, for box art — matched by `app_id`. Absent while `/library` is still loading. */
library?: GameEntry[];
onEnd: (game: ActiveGame) => void;
isEnding: boolean;
}> = ({ games, library, onEnd, isEnding }) => {
if (games.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gamepad2 className="size-4" />
{m.games_title()}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{games.map((g) => (
<GameRow
// A host can run the same title for two clients at once (native admits concurrent
// sessions), so the title alone is not a key.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
game={g}
art={coverFor(g, library)}
onEnd={() => onEnd(g)}
isEnding={isEnding}
/>
))}
</CardContent>
</Card>
);
};
const GameRow: FC<{
game: ActiveGame;
art?: string;
onEnd: () => void;
isEnding: boolean;
}> = ({ game, art, onEnd, isEnding }) => {
const waiting = game.state === "grace";
return (
<div className="flex items-center gap-3">
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
{art && (
<img
src={art}
alt=""
loading="lazy"
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{game.title}</span>
<Badge variant={waiting ? "destructive" : stateVariant(game.state)}>
{stateLabel(game.state)}
</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{waiting
? m.games_closing_in({
time: formatCountdown(game.grace_remaining_s ?? 0),
})
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
</p>
</div>
<Button
variant={waiting ? "destructive" : "outline"}
size="sm"
disabled={isEnding}
onClick={onEnd}
>
<XCircle className="size-3.5" />
{m.games_end_now()}
</Button>
</div>
);
};
/**
* Box art for a running game, matched against the already-fetched catalog by store-qualified id —
* so this card costs no extra request. `undefined` for an operator-typed GameStream command (no
* catalog entry behind it) or a title whose entry carries no art.
*/
function coverFor(game: ActiveGame, library?: GameEntry[]): string | undefined {
if (!game.app_id || !library) return undefined;
const entry = library.find((e) => e.id === game.app_id);
return entry?.art.portrait ?? entry?.art.header ?? undefined;
}
/** `mm:ss` — the countdown reads as a duration, not a number of seconds. */
function formatCountdown(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function stateLabel(state: string): string {
switch (state) {
case "launching":
return m.games_state_launching();
case "running":
return m.games_state_running();
case "exited":
return m.games_state_exited();
case "grace":
return m.games_state_grace();
default:
return state;
}
}
function stateVariant(state: string): "success" | "secondary" | "outline" {
if (state === "running") return "success";
if (state === "launching") return "secondary";
return "outline";
}
/** The compat plane is worth naming: it is why a row may show an IP instead of a device name. */
function planeLabel(plane: string): string {
return plane === "gamestream" ? "GameStream" : "";
}
+33 -1
View File
@@ -1,7 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useRequestIdr, useStopSession } from "@/api/gen/session/session";
import { useGetLibrary } from "@/api/gen/library/library";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import {
useEndGame,
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { useLocale } from "@/lib/i18n";
import { DashboardView } from "./view";
@@ -10,19 +16,45 @@ export const SectionDashboard: FC = () => {
const qc = useQueryClient();
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_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, {
query: { staleTime: 5 * 60_000 },
});
const stop = useStopSession();
const idr = useRequestIdr();
const endGame = useEndGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
/**
* "End now" means two different things, and which one is right follows from the row's state: a
* game whose session is still live ends by stopping that session (what then happens to the game
* follows the operator's policy — stopping a session is not licence to close a game), while a
* game already waiting out its reconnect window has no session left to stop and is ended directly.
*/
const onEndGame = (game: ActiveGame) => {
if (game.state === "grace") {
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate },
);
} else {
stop.mutate(undefined, { onSuccess: invalidate });
}
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)}
onEndGame={onEndGame}
isStopping={stop.isPending}
isRequestingIdr={idr.isPending}
isEndingGame={endGame.isPending || stop.isPending}
/>
);
};
+25 -1
View File
@@ -1,6 +1,8 @@
import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
@@ -8,14 +10,27 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
status: Loadable<RuntimeStatus>;
library?: GameEntry[];
onStopSession: () => void;
onRequestIdr: () => void;
onEndGame: (game: ActiveGame) => void;
isStopping: boolean;
isRequestingIdr: boolean;
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
isEndingGame: boolean;
}> = ({
status,
library,
onStopSession,
onRequestIdr,
onEndGame,
isStopping,
isRequestingIdr,
isEndingGame,
}) => {
const s = status.data;
return (
<Section maxWidth={false}>
@@ -61,6 +76,15 @@ export const DashboardView: FC<{
</Card>
</div>
{/* Above the session card: a game the host is about to close is the most
time-sensitive thing on this page. */}
<RunningGames
games={s.games}
library={library}
onEnd={onEndGame}
isEnding={isEndingGame}
/>
<Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
@@ -0,0 +1,206 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
useSetSessionSettings,
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
/**
* Whether a launched game and its streaming session share a fate
* (design/session-game-lifetime.md), next to the display keep-alive policy because the two interact:
* a kept display and a kept game are separate decisions with separate timers, and `keep_alive:
* forever` outranks the game policy for the display itself.
*
* Every axis saves on change (like the display policy above) — there is no Save button to miss.
*/
export const SessionGameCard: FC = () => {
const qc = useQueryClient();
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
// controls are shown disabled rather than hidden — "does nothing here" is information.
const enforced = q.data?.enforced ?? [];
const acts = (field: string) =>
enforced.length === 0 || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
const [grace, setGrace] = useState("");
useEffect(() => {
if (server) setGrace(String(server.disconnect_grace_seconds ?? 300));
}, [server]);
const apply = (patch: Partial<SessionSettings>) => {
if (!server) return;
save.mutate(
{ data: { ...server, ...patch } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetSessionSettingsQueryKey() });
toast.success(m.session_game_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
return (
<Card>
<CardHeader>
<CardTitle>{m.session_game_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.session_game_help()}
</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{server && (
<div className="space-y-6">
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
>
<div className="flex flex-wrap gap-2">
<Choice
selected={server.session_on_game_exit === true}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: true })}
>
{m.session_game_on_exit_end()}
</Choice>
<Choice
selected={server.session_on_game_exit === false}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: false })}
>
{m.session_game_on_exit_keep()}
</Choice>
</div>
</Field>
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_session_end ?? "keep") === p}
disabled={busy || !acts("game_on_session_end")}
onClick={() => apply({ game_on_session_end: p })}
>
{END_POLICY_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_session_end ?? "keep") === "always" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_always_warning()}
</p>
)}
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={10}
max={86400}
className="w-28"
value={grace}
disabled={busy || !acts("disconnect_grace_seconds")}
onChange={(e) => setGrace(e.target.value)}
onBlur={() => {
const n = Number(grace);
if (!Number.isFinite(n)) {
setGrace(
String(server.disconnect_grace_seconds ?? 300),
);
return;
}
// The host clamps to 10..=86400 and returns what it stored, so
// a nonsense number is corrected rather than rejected.
if (n !== server.disconnect_grace_seconds) {
apply({ disconnect_grace_seconds: n });
}
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</Field>
)}
{enforced.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
keep: () => m.session_game_end_keep(),
on_quit: () => m.session_game_end_on_quit(),
always: () => m.session_game_end_always(),
};
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Choice: FC<{
selected: boolean;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}> = ({ selected, disabled, onClick, children }) => (
<Button
type="button"
variant={selected ? "default" : "outline"}
size="sm"
disabled={disabled}
aria-pressed={selected}
className={cn(disabled && "opacity-60")}
onClick={onClick}
>
{children}
</Button>
);
+6
View File
@@ -3,11 +3,16 @@ import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard";
import { SessionGameCard } from "./SessionGameCard";
/**
* The **Virtual displays** page (design/display-management.md): the host's virtual-display policy
* (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav
* section — the config surface is large enough to warrant the room, and it kept the Host page busy.
*
* The session⇄game lifetime card sits here rather than on its own page because it is the same
* question one step further out: keep-alive decides how long a *display* outlives a disconnect, and
* this decides whether the *game* does.
*/
export const SectionDisplays: FC = () => {
useLocale();
@@ -16,6 +21,7 @@ export const SectionDisplays: FC = () => {
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
<SessionGameCard />
</div>
</Section>
);