forked from unom/punktfunk
The Logs page died permanently every time the host restarted — which the console's own update flow does. The host's log ring restarts at seq 1 while the page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh ring is not an error, it is an empty page forever: no error, no dropped badge, stale lines on screen, and nothing short of a full reload to get out. A restart always breaks the poll first, so a failed poll now triggers a re-read from the start of the ring, and a page whose newest entry is older than what we hold is recognised as the sequence having restarted. Follow mode also stopped following at exactly the wrong moment. The autoscroll effect was keyed on the rendered row count, which pins at the 1000-row DOM cap — so once the log got busy enough to matter, the effect never re-ran again. It is keyed on the newest rendered seq now. And pausing now actually pauses: stopping the interval left React Query's focus/reconnect refetches landing, which evicted the very lines the operator had paused on. The rest: - A plugin could white-screen the whole console by registering `icon: "constructor"`. The icon map is a plain object, so the inherited key resolved to `Object`, which is truthy — the fallback never fired and React was handed `Object` as a component, from inside the app shell. - Saving a display arrangement deleted the saved position of every device that was not connected at that moment: the host replaces the whole map, and we only ever sent the displays we could see. - Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits the Custom block was holding, then cleared the "unsaved" badge so there was no trace of it. Those three apply on top of the SAVED policy now. - Saving the Custom block put the streamed-screen pin back to whatever it was when the form was seeded, undoing a change made in the picker below it. - "End now" on a running game calls the host's only stop, which ends EVERY live session; on a grace row with no app id it ended every waiting game. Both say so first now, when there is more than one to lose. - Edit and Delete were offered on library entries owned by a provider plugin, which the host refuses with 409 — silently. They are attributed instead. - An install whose first poll failed never polled again, and one whose host restarted spun forever with no way to dismiss it. - Submitting a second pairing PIN showed the previous attempt's "PIN sent" before a digit was typed, and the paired list it points you at never refreshed. - The streamed-screen picker claimed an env pin during every slow load, and rows that cannot be picked now look that way instead of silently eating the click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "@unom/ui/toast";
|
|
import type { FC } from "react";
|
|
import { ApiError } from "@/api/fetcher";
|
|
import {
|
|
getGetDisplayMonitorsQueryKey,
|
|
getGetDisplaySettingsQueryKey,
|
|
useGetDisplayMonitors,
|
|
useGetDisplaySettings,
|
|
useSetDisplaySettings,
|
|
} from "@/api/gen/display/display";
|
|
import type { ApiMonitorInfo } 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";
|
|
import { cn } from "@/lib/utils";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
/**
|
|
* **Streamed screen** (design/per-monitor-portal-capture.md §5.3): stream one of the host's real
|
|
* monitors instead of creating a virtual display per client.
|
|
*
|
|
* Deliberately a HOST-wide choice, not per-client — it is the decision of record for this feature,
|
|
* and it is also what keeps input honest: the injector is host-lifetime and shared by every
|
|
* concurrent session, so "which screen do absolute coordinates land on" can only have one answer.
|
|
*
|
|
* Saves on selection (like the policy card above) — there is no Save button to miss. The pin is a
|
|
* field of the display policy, so it rides the same PUT.
|
|
*/
|
|
export const MonitorCard: FC = () => {
|
|
const qc = useQueryClient();
|
|
const monitors = useGetDisplayMonitors();
|
|
const settings = useGetDisplaySettings();
|
|
const save = useSetDisplaySettings();
|
|
|
|
const policy = settings.data?.settings;
|
|
const rows = monitors.data?.monitors ?? [];
|
|
// The host reports the EFFECTIVE pin — an env-pinned appliance shows its real answer here even
|
|
// though the console cannot change it.
|
|
const pinned = monitors.data?.pinned ?? null;
|
|
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
|
|
// environment is read-only here: offering controls that silently lose to the env would be worse
|
|
// than saying so.
|
|
//
|
|
// Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed)
|
|
// `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env
|
|
// pin that may not exist and go read-only on every slow load.
|
|
const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned;
|
|
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
|
|
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
|
|
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
|
|
// still created on connect. Defaults to TRUE when the field is absent so an older host — which
|
|
// only ever shipped this picker where it worked — is not retroactively locked out.
|
|
const pinSupported = monitors.data?.pin_supported ?? true;
|
|
// Both reasons produce the same read-only card; only the explanation above it differs.
|
|
const locked = envLocked || !pinSupported;
|
|
|
|
const choose = (connector: string | null) => {
|
|
if (!policy || locked) return;
|
|
save.mutate(
|
|
{ data: { ...policy, capture_monitor: connector } },
|
|
{
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
|
qc.invalidateQueries({ queryKey: getGetDisplayMonitorsQueryKey() });
|
|
toast.success(m.display_monitor_saved());
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const busy = save.isPending;
|
|
const error = save.error instanceof ApiError ? save.error.message : undefined;
|
|
|
|
const row = (
|
|
key: string,
|
|
selected: boolean,
|
|
title: string,
|
|
hint: string,
|
|
tags?: ReturnType<typeof Badge>[],
|
|
onSelect?: () => void,
|
|
) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
disabled={busy || locked || !onSelect}
|
|
onClick={onSelect}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
|
|
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
|
|
// `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own
|
|
// virtual displays. It was styled exactly like a selectable row and silently swallowed
|
|
// every click; it is listed so "why isn't my monitor here?" has an answer, so it has to
|
|
// LOOK unavailable too.
|
|
(busy || locked || !onSelect) && "cursor-not-allowed opacity-60",
|
|
)}
|
|
>
|
|
<span className="flex flex-col gap-1">
|
|
<span className="flex items-center gap-2 font-medium">
|
|
{title}
|
|
{tags}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">{hint}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
const monitorRow = (mon: ApiMonitorInfo) => {
|
|
const tags = [
|
|
mon.primary ? (
|
|
<Badge key="p" variant="secondary">
|
|
{m.display_monitor_primary()}
|
|
</Badge>
|
|
) : null,
|
|
!mon.enabled ? (
|
|
<Badge key="d" variant="outline">
|
|
{m.display_monitor_disabled()}
|
|
</Badge>
|
|
) : null,
|
|
].filter(Boolean) as ReturnType<typeof Badge>[];
|
|
return row(
|
|
mon.connector,
|
|
pinned?.toLowerCase() === mon.connector.toLowerCase(),
|
|
`${mon.connector} — ${mon.description}`,
|
|
`${mon.mode} · ${m.display_monitor_mirror_hint()}`,
|
|
tags,
|
|
// A disabled head cannot be streamed (the host refuses with that reason), so don't
|
|
// offer it as a choice — it is listed so "why isn't it here?" has an answer.
|
|
mon.enabled && !mon.managed ? () => choose(mon.connector) : undefined,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{m.display_monitor_title()}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="max-w-prose text-sm text-muted-foreground">
|
|
{m.display_monitor_intro()}
|
|
</p>
|
|
{!pinSupported && (
|
|
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
{m.display_monitor_unsupported()}
|
|
</p>
|
|
)}
|
|
{pinSupported && envLocked && (
|
|
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
{m.display_monitor_env_locked()}
|
|
</p>
|
|
)}
|
|
<QueryState
|
|
isLoading={monitors.isLoading}
|
|
error={monitors.error}
|
|
refetch={monitors.refetch}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{row(
|
|
"__virtual__",
|
|
!pinned,
|
|
m.display_monitor_virtual(),
|
|
m.display_monitor_virtual_hint(),
|
|
undefined,
|
|
() => choose(null),
|
|
)}
|
|
{rows.map(monitorRow)}
|
|
{rows.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{monitors.data?.error
|
|
? m.display_monitor_unavailable()
|
|
: m.display_monitor_none()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</QueryState>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|