Files
punktfunk/web/src/sections/Logs/LogsCard.tsx
T
enricobuehler 0b252403cd
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m35s
ci / web (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 9m12s
nix / flake (pull_request) Failing after 19m50s
fix(web): fix the card inset at the root, not at the call sites
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.

Two faults, both in components/ui/card.tsx:

1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
   resolves conflicts only within a variant, so any call-site override won at the base
   and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
   Displays card before this change: padding-top 24px at 500px, 0px at 1440px.

2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
   above me and supplies the top inset". Delete the header, which is exactly what tabbing
   a page does since the tab label replaces the card title, and the top inset silently
   vanishes at ≥640px.

Fix:

- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
  @unom/ui's own Card uses, so nested cards finally agree on their inset. A single
  variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
  the author. A headerless CardContent keeps its inset with nothing to remember.

Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.

`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.

Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.

Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
2026-08-09 17:23:09 +02:00

396 lines
14 KiB
TypeScript

import { toast } from "@unom/ui/toast";
import { Copy, Download, Pause, Play, Share2, Trash2 } from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { useLogsGet } from "@/api/gen/logs/logs";
import type { LogEntry } from "@/api/gen/model/logEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import {
detectShareMode,
downloadText,
logFilename,
logsToText,
type ShareMode,
shareLogs,
} from "./export";
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
type MinLevel = (typeof LEVELS)[number];
const RANK: Record<string, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
const LEVEL_CLASS: Record<string, string> = {
ERROR: "text-red-400",
WARN: "text-amber-400",
INFO: "text-sky-300",
DEBUG: "text-muted-foreground",
TRACE: "text-muted-foreground",
};
const KEEP = 5_000; // accumulated entries (client memory bound)
const SHOW = 1_000; // rendered rows (DOM bound)
/**
* Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner
* ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:<name>`. Without this the two
* are interleaved with nothing but the target column to tell them apart, and "show me what my
* plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:`
* into the search box.
*/
const SOURCES = ["all", "host", "plugins"] as const;
type Source = (typeof SOURCES)[number];
/** The target prefix the host stamps on every runner-shipped line. */
const PLUGIN_TARGET_PREFIX = "plugin:";
const matchesSource = (target: string, source: Source): boolean =>
source === "all" ||
(source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX);
const SOURCE_LABEL: Record<Source, () => string> = {
all: () => m.logs_source_all(),
host: () => m.logs_source_host(),
plugins: () => m.logs_source_plugins(),
};
/**
* Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key,
* so the next page fetches immediately and a backlog drains fast; an empty page leaves the key
* unchanged and `refetchInterval` paces the idle poll. Pausing (follow off) stops the interval.
*/
export const LogsSection: FC = () => {
const [cursor, setCursor] = useState(0);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
// Set while a poll has failed and we have not yet re-read the ring from the start.
const [resync, setResync] = useState(false);
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
// on hydration. Until then the share button is simply absent.
useEffect(() => {
setShareMode(detectShareMode());
}, []);
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{
query: {
refetchInterval: follow ? 2_000 : false,
// Pausing must actually pause. Stopping only the interval left React Query's default
// focus/reconnect refetches landing, and the append effect consumed them
// unconditionally — so tabbing away and back evicted the lines the operator had
// paused on, from behind the pause button.
refetchOnWindowFocus: follow,
refetchOnReconnect: follow,
},
},
);
// Resync after the host goes away and comes back.
//
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
// no error, no dropped badge and no way back short of a full reload. The console's own update
// flow restarts the host, so this was reachable from two clicks away.
//
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
// we re-read from the start of the ring once and let the effect below decide whether the
// sequence actually regressed.
const failed = query.isError;
useEffect(() => {
if (failed) setResync(true);
}, [failed]);
useEffect(() => {
if (resync && cursor !== 0) setCursor(0);
}, [resync, cursor]);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
const lastSeq = prev.at(-1)?.seq ?? -1;
// A page whose newest entry is OLDER than what we already hold can only mean the host's
// sequence restarted underneath us — the buffer describes a host that no longer exists,
// so replace it wholesale rather than filtering every new line away as "already seen".
const newest = data.entries.at(-1)?.seq ?? -1;
if (newest < lastSeq) return data.entries.slice(-KEEP);
// Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a
// double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending
// the same page twice (duplicate rows + duplicate React keys), and makes the post-resync
// re-read from 0 a no-op when the host did NOT restart.
const fresh = data.entries.filter((e) => e.seq > lastSeq);
return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev;
});
setDropped((d) => d || data.dropped);
setCursor(data.next);
setResync(false);
}, [data]);
// The card hands back the entries its filters currently match, so an export carries exactly what
// the viewer shows — never the DOM-bounded tail of it.
return (
<LogsCard
entries={entries}
follow={follow}
onFollow={setFollow}
onClear={() => {
setEntries([]);
setDropped(false);
}}
onDownload={(shown) =>
downloadText(logsToText(shown), logFilename(new Date()))
}
onShare={async (shown) => {
const outcome = await shareLogs(
logsToText(shown),
logFilename(new Date()),
);
if (outcome === "copied") toast.success(m.logs_copied());
else if (outcome === "failed") toast.error(m.logs_share_failed());
}}
shareMode={shareMode}
dropped={dropped}
error={query.error}
isLoading={query.isLoading}
onRetry={() => query.refetch()}
/>
);
};
/**
* Pure log viewer: level/min filter + text search (local UI state), follow, clear, and export.
* Export is the filters' full result, not the rendered tail — the `SHOW` cap is a DOM budget and
* has no business truncating a file destined for a bug report.
*/
export const LogsCard: FC<{
entries: LogEntry[];
follow: boolean;
onFollow: (follow: boolean) => void;
onClear: () => void;
onDownload: (shown: LogEntry[]) => void;
onShare: (shown: LogEntry[]) => void;
shareMode: ShareMode | null;
dropped: boolean;
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
error?: unknown;
isLoading?: boolean;
onRetry?: () => void;
}> = ({
entries,
follow,
onFollow,
onClear,
onDownload,
onShare,
shareMode,
dropped,
error,
isLoading,
onRetry,
}) => {
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
const [source, setSource] = useState<Source>("all");
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const matched = useMemo(() => {
const min = RANK[minLevel] ?? 0;
const q = search.trim().toLowerCase();
return entries.filter(
(e) =>
(RANK[e.level] ?? 0) >= min &&
matchesSource(e.target, source) &&
(q === "" ||
e.msg.toLowerCase().includes(q) ||
e.target.toLowerCase().includes(q)),
);
}, [entries, minLevel, source, search]);
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
// Keep the tail in view while following.
//
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
// so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then
// stopped re-running and follow-mode quietly stopped following — exactly when the log is busy
// enough to need it. The newest seq keeps changing for as long as lines arrive.
const newestVisible = visible.at(-1)?.seq ?? -1;
// NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
// quietly stops following. The same warning was here before, on `visible.length`.
useEffect(() => {
if (!follow) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [follow, newestVisible]);
return (
<Card>
{/* No CardHeader here, and that no longer needs saying: CardContent keeps its top inset
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
<Button
key={l}
size="sm"
variant={minLevel === l ? "secondary" : "ghost"}
onClick={() => setMinLevel(l)}
>
{l}
</Button>
))}
</div>
<div className="flex items-center gap-1 border-l pl-2">
{SOURCES.map((s) => (
<Button
key={s}
size="sm"
variant={source === s ? "secondary" : "ghost"}
onClick={() => setSource(s)}
>
{SOURCE_LABEL[s]()}
</Button>
))}
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={m.logs_search()}
className="max-w-xs"
/>
<div className="ml-auto flex items-center gap-2">
{dropped && <Badge variant="secondary">{m.logs_dropped()}</Badge>}
<Button
size="icon"
variant="ghost"
disabled={matched.length === 0}
title={m.logs_download()}
aria-label={m.logs_download()}
onClick={() => onDownload(matched)}
>
<Download className="size-4" />
</Button>
{shareMode && (
<Button
size="icon"
variant="ghost"
disabled={matched.length === 0}
title={shareLabel}
aria-label={shareLabel}
onClick={() => onShare(matched)}
>
{shareMode === "share" ? (
<Share2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
)}
<Button
size="sm"
variant={follow ? "secondary" : "outline"}
onClick={() => onFollow(!follow)}
>
{follow ? (
<Pause className="mr-1 size-3.5" />
) : (
<Play className="mr-1 size-3.5" />
)}
{follow ? m.logs_pause() : m.logs_follow()}
</Button>
<Button size="sm" variant="ghost" onClick={onClear}>
<Trash2 className="mr-1 size-3.5" />
{m.logs_clear()}
</Button>
</div>
</div>
{/* A failing poll while lines are already on screen keeps them there — during a host
restart the last lines before it went away are the interesting ones — but says so,
instead of letting a frozen view read as a quiet host. */}
{error != null && entries.length > 0 && (
<p
role="status"
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{m.logs_stalled()}
</p>
)}
<div
ref={listRef}
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{visible.length === 0 ? (
// An empty list has three quite different causes and used to render one sentence
// for all of them: the host is quiet, the request failed, or it hasn't answered yet.
<div className="p-2">
{error ? (
<div className="space-y-2 font-sans">
<p className="text-destructive">{m.common_error()}</p>
{onRetry && (
<Button size="sm" variant="outline" onClick={onRetry}>
{m.common_retry()}
</Button>
)}
</div>
) : (
<p className="text-muted-foreground">
{isLoading
? m.common_loading()
: // "No plugin output" has a specific, actionable cause that the generic
// "adjust the filter" line actively misdirects from: the runner is a
// separate service and is opt-in on Linux, so the usual reason for an
// empty Plugins view is that it simply isn't running.
source === "plugins"
? m.logs_empty_plugins()
: m.logs_empty()}
</p>
)}
</div>
) : (
visible.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
<span className="text-muted-foreground">
{fmtTime(e.ts_ms)}{" "}
</span>
<span
className={cn(
"font-medium",
LEVEL_CLASS[e.level] ?? "text-muted-foreground",
)}
>
{e.level.padEnd(5)}{" "}
</span>
<span className="text-muted-foreground">{e.target} </span>
<span>{e.msg}</span>
</div>
))
)}
</div>
</CardContent>
</Card>
);
};
const fmtTime = (ts: number): string => {
const d = new Date(ts);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
};