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
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.
396 lines
14 KiB
TypeScript
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)}`;
|
|
};
|