Client logs join the host log on one timeline instead of a table below it #274
@@ -856,7 +856,11 @@ mod tests {
|
||||
let mask = plane(&[0, 0, 1, 1]);
|
||||
let out = masked_color_to_rgba(&color, &mask, 4, 1);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 colour=0 ⇒ black");
|
||||
assert_eq!(px(&out, 1), [0xCC, 0, 0, 0xFF], "AND=0 colour ⇒ opaque colour");
|
||||
assert_eq!(
|
||||
px(&out, 1),
|
||||
[0xCC, 0, 0, 0xFF],
|
||||
"AND=0 colour ⇒ opaque colour"
|
||||
);
|
||||
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
|
||||
// so the outline claims it — same as the monochrome table.
|
||||
assert_eq!(
|
||||
@@ -864,7 +868,11 @@ mod tests {
|
||||
OPAQUE_WHITE,
|
||||
"outline grows into adjacent transparency"
|
||||
);
|
||||
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 colour≠0 ⇒ invert, not drop");
|
||||
assert_eq!(
|
||||
px(&out, 3),
|
||||
OPAQUE_BLACK,
|
||||
"AND=1 colour≠0 ⇒ invert, not drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// AND=1 and a zero colour pixel stays transparent when nothing invert-neighbours it.
|
||||
|
||||
@@ -439,9 +439,14 @@
|
||||
"diag_uinput_access_title": "Unterstützung für virtuelle Controller",
|
||||
"diag_server_conflict_title": "Konkurrierender Streaming-Server",
|
||||
"logs_title": "Logs",
|
||||
"logs_source_all": "Alle",
|
||||
"logs_sources_label": "Quellen",
|
||||
"logs_sources_none": "Keine Quelle ausgewählt — wähle oben mindestens eine aus.",
|
||||
"logs_devices_empty": "Noch kein Gerät hat Logs geschickt — nutze „Logs an Host senden“ im Host-Menü eines Clients.",
|
||||
"logs_source_host": "Host",
|
||||
"logs_source_plugins": "Plugins",
|
||||
"logs_export_all": "Alles exportieren",
|
||||
"logs_export_all_working": "Wird gesammelt…",
|
||||
"logs_export_all_failed": "Export konnte nicht erstellt werden",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
@@ -455,8 +460,7 @@
|
||||
"logs_copy": "Logs in die Zwischenablage kopieren",
|
||||
"logs_copied": "Logs in die Zwischenablage kopiert",
|
||||
"logs_share_failed": "Logs konnten nicht geteilt werden",
|
||||
"client_logs_title": "Client-Logs",
|
||||
"client_logs_subtitle": "Log-Pakete, die deine Geräte mit „Logs an Host senden“ geschickt haben — von Plattformen, deren eigene Dateien unerreichbar sind, etwa einem Steam Deck im Gaming Mode oder einem Apple TV.",
|
||||
"client_logs_manage": "{count} hochgeladene Pakete verwalten",
|
||||
"client_logs_col_received": "Empfangen",
|
||||
"client_logs_col_device": "Gerät",
|
||||
"client_logs_col_size": "Größe",
|
||||
|
||||
@@ -439,9 +439,14 @@
|
||||
"diag_uinput_access_title": "Virtual controller support",
|
||||
"diag_server_conflict_title": "Competing streaming server",
|
||||
"logs_title": "Logs",
|
||||
"logs_source_all": "All",
|
||||
"logs_sources_label": "Sources",
|
||||
"logs_sources_none": "No sources selected — pick at least one above.",
|
||||
"logs_devices_empty": "No device has sent logs yet — use “Send logs to host” in a client's host menu.",
|
||||
"logs_source_host": "Host",
|
||||
"logs_source_plugins": "Plugins",
|
||||
"logs_export_all": "Export all",
|
||||
"logs_export_all_working": "Collecting…",
|
||||
"logs_export_all_failed": "Couldn't assemble the export",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
@@ -455,8 +460,7 @@
|
||||
"logs_copy": "Copy logs to clipboard",
|
||||
"logs_copied": "Logs copied to clipboard",
|
||||
"logs_share_failed": "Couldn't share the logs",
|
||||
"client_logs_title": "Client logs",
|
||||
"client_logs_subtitle": "Log bundles your devices sent with “Send logs to host” — from platforms whose own files are out of reach, like a Steam Deck in Gaming Mode or an Apple TV.",
|
||||
"client_logs_manage": "Manage {count} uploaded bundles",
|
||||
"client_logs_col_received": "Received",
|
||||
"client_logs_col_device": "Device",
|
||||
"client_logs_col_size": "Size",
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import { ChevronDown, ChevronRight, Download, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
clientLogsGet,
|
||||
getClientLogsListQueryKey,
|
||||
useClientLogsDelete,
|
||||
useClientLogsList,
|
||||
} from "@/api/gen/logs/logs";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,14 +31,20 @@ const fmtSize = (bytes: number): string =>
|
||||
: `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
||||
/**
|
||||
* Container: log bundles paired clients uploaded via "Send logs to host" — the log-escape hatch
|
||||
* for platforms whose own files the user can't reach (a Deck in Gaming Mode, tvOS). Owns the
|
||||
* list query, the text download, and delete.
|
||||
* Container for bundle housekeeping: fetch the raw file, delete one.
|
||||
*
|
||||
* Reading a bundle is no longer this component's job — the device chips merge it into the viewer
|
||||
* above. What is left is the file-cabinet half (keep the raw bytes, throw one away), which is
|
||||
* occasional and belongs behind a disclosure rather than in a card of its own competing with the
|
||||
* log for the top of the page.
|
||||
*/
|
||||
export const ClientLogsSection: FC = () => {
|
||||
export const ClientLogsSection: FC<{
|
||||
list: Loadable<ClientLogMeta[]>;
|
||||
/** Drop a deleted bundle's rows from the viewer — the list alone cannot do that. */
|
||||
onDeleted: (id: string) => void;
|
||||
}> = ({ list, onDeleted }) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
const bundles = useClientLogsList();
|
||||
const del = useClientLogsDelete();
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
@@ -54,8 +58,10 @@ export const ClientLogsSection: FC = () => {
|
||||
del.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() }),
|
||||
onSuccess: () => {
|
||||
onDeleted(id);
|
||||
qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() });
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e) ?? m.client_logs_delete_failed()),
|
||||
},
|
||||
@@ -82,7 +88,7 @@ export const ClientLogsSection: FC = () => {
|
||||
|
||||
return (
|
||||
<ClientLogsCard
|
||||
bundles={bundles}
|
||||
bundles={list}
|
||||
onDownload={onDownload}
|
||||
onDelete={onDelete}
|
||||
isDeleting={del.isPending}
|
||||
@@ -90,89 +96,103 @@ export const ClientLogsSection: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Uploaded client log bundles, newest first, with Download / Delete row actions. */
|
||||
/**
|
||||
* Uploaded bundles as a collapsed disclosure: Download (raw) / Delete per row.
|
||||
*
|
||||
* Collapsed by default because it answers a question nobody arrives with. It renders nothing at all
|
||||
* when there is nothing stored — the "no device has sent logs yet" hint now lives beside the source
|
||||
* chips, where someone who has never used the feature will actually meet it.
|
||||
*/
|
||||
export const ClientLogsCard: FC<{
|
||||
bundles: Loadable<ClientLogMeta[]>;
|
||||
onDownload: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isDeleting: boolean;
|
||||
}> = ({ bundles, onDownload, onDelete, isDeleting }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows = bundles.data ?? [];
|
||||
// No bundles is the ordinary state (nothing was ever sent) — an empty card would just be
|
||||
// noise on every visit, so the whole card only appears once something arrived. Errors and
|
||||
// loading still render: a broken list must not look like "nothing was sent".
|
||||
// Errors and loading still render: a broken list must not look like "nothing was sent".
|
||||
if (!bundles.isLoading && !bundles.error && rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-medium">{m.client_logs_title()}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.client_logs_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<QueryState
|
||||
isLoading={bundles.isLoading}
|
||||
error={bundles.error}
|
||||
refetch={bundles.refetch}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<CardContent flush>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.client_logs_col_received()}</TableHead>
|
||||
<TableHead>{m.client_logs_col_device()}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{m.client_logs_col_size()}
|
||||
</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="whitespace-nowrap font-medium">
|
||||
{fmtTimestamp(r.received_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span>{r.device_name}</span>
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{r.fingerprint_prefix}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmtSize(r.size_bytes)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_download()}
|
||||
title={m.client_logs_download()}
|
||||
onClick={() => onDownload(r.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_delete()}
|
||||
title={m.client_logs_delete()}
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(r.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
{m.client_logs_manage({ count: rows.length })}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<QueryState
|
||||
isLoading={bundles.isLoading}
|
||||
error={bundles.error}
|
||||
refetch={bundles.refetch}
|
||||
>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.client_logs_col_received()}</TableHead>
|
||||
<TableHead>{m.client_logs_col_device()}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{m.client_logs_col_size()}
|
||||
</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</QueryState>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="whitespace-nowrap font-medium">
|
||||
{fmtTimestamp(r.received_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span>{r.device_name}</span>
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{r.fingerprint_prefix}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmtSize(r.size_bytes)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_download()}
|
||||
title={m.client_logs_download()}
|
||||
onClick={() => onDownload(r.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_delete()}
|
||||
title={m.client_logs_delete()}
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(r.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</QueryState>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+149
-190
@@ -1,22 +1,29 @@
|
||||
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 {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
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 { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import {
|
||||
detectShareMode,
|
||||
downloadText,
|
||||
logFilename,
|
||||
logsToText,
|
||||
type ShareMode,
|
||||
shareLogs,
|
||||
} from "./export";
|
||||
import type { ShareMode } from "./export";
|
||||
import { PLUGINS_SOURCE, type Row } from "./rows";
|
||||
|
||||
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
|
||||
type MinLevel = (typeof LEVELS)[number];
|
||||
@@ -35,151 +42,48 @@ const LEVEL_CLASS: Record<string, string> = {
|
||||
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.
|
||||
* One selectable producer. Host and plugins are always offered; devices appear as their bundles
|
||||
* arrive.
|
||||
*/
|
||||
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(),
|
||||
};
|
||||
export interface SourceChoice {
|
||||
id: string;
|
||||
label: string;
|
||||
/** A device chip's second line — when that bundle landed. */
|
||||
hint?: string;
|
||||
selected: boolean;
|
||||
/** Fetching the bundle, or failed to. Host and plugins are never either. */
|
||||
state?: "loading" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The log viewer: one pane, one timeline, every producer on it.
|
||||
*
|
||||
* The source control is **multi-select**, which is the whole point rather than a detail. The old
|
||||
* `All | Host | Plugins` strip could only ever isolate one producer, so the question that actually
|
||||
* brings someone here — "the client stalled at 12:03:47; what was the host doing?" — had no view
|
||||
* at all. Any combination is now expressible, and Host + one device is the interesting one.
|
||||
*
|
||||
* A line that does not parse still renders (see `rows.ts`), and the level/search filters treat an
|
||||
* unparsed row as level-less rather than hiding it: a filter must never be the reason a log looks
|
||||
* empty when it isn't.
|
||||
*/
|
||||
export const LogsCard: FC<{
|
||||
entries: LogEntry[];
|
||||
/** Every loaded row, merged and sorted. The card filters; the caller does not pre-filter. */
|
||||
rows: Row[];
|
||||
sources: SourceChoice[];
|
||||
onToggleSource: (id: string) => void;
|
||||
/** No device has ever uploaded — the hint that teaches the feature exists. */
|
||||
devicesEmpty?: boolean;
|
||||
/** Bundle housekeeping (download raw / delete), tucked under the viewer. */
|
||||
manage?: ReactNode;
|
||||
follow: boolean;
|
||||
onFollow: (follow: boolean) => void;
|
||||
onClear: () => void;
|
||||
onDownload: (shown: LogEntry[]) => void;
|
||||
onShare: (shown: LogEntry[]) => void;
|
||||
onDownload: (shown: Row[]) => void;
|
||||
onShare: (shown: Row[]) => void;
|
||||
shareMode: ShareMode | null;
|
||||
dropped: boolean;
|
||||
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
|
||||
@@ -187,7 +91,11 @@ export const LogsCard: FC<{
|
||||
isLoading?: boolean;
|
||||
onRetry?: () => void;
|
||||
}> = ({
|
||||
entries,
|
||||
rows,
|
||||
sources,
|
||||
onToggleSource,
|
||||
devicesEmpty,
|
||||
manage,
|
||||
follow,
|
||||
onFollow,
|
||||
onClear,
|
||||
@@ -200,32 +108,47 @@ export const LogsCard: FC<{
|
||||
onRetry,
|
||||
}) => {
|
||||
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
|
||||
const [source, setSource] = useState<Source>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => new Set(sources.filter((s) => s.selected).map((s) => s.id)),
|
||||
[sources],
|
||||
);
|
||||
|
||||
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) &&
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
selected.has(r.source) &&
|
||||
// An unparsed row has no level to rank. Ranking it as 0 would hide it behind any
|
||||
// filter above DEBUG, which is the one outcome a fail-soft parser must not produce —
|
||||
// so it is always in range and only the text search can exclude it.
|
||||
(r.level === "" || (RANK[r.level] ?? 0) >= min) &&
|
||||
(q === "" ||
|
||||
e.msg.toLowerCase().includes(q) ||
|
||||
e.target.toLowerCase().includes(q)),
|
||||
r.msg.toLowerCase().includes(q) ||
|
||||
r.target.toLowerCase().includes(q) ||
|
||||
(r.device?.toLowerCase().includes(q) ?? false)),
|
||||
);
|
||||
}, [entries, minLevel, source, search]);
|
||||
}, [rows, selected, minLevel, search]);
|
||||
|
||||
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
|
||||
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
|
||||
const nothingSelected = selected.size === 0;
|
||||
// "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. Only worth saying
|
||||
// when plugins are the ONLY thing being looked at — otherwise the emptiness is not about them.
|
||||
const onlyPlugins = selected.size === 1 && selected.has(PLUGINS_SOURCE);
|
||||
|
||||
// Keep the tail in view while following.
|
||||
//
|
||||
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
|
||||
// Keyed on the newest RENDERED key, 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;
|
||||
// enough to need it. The newest key keeps changing for as long as lines arrive.
|
||||
const newestVisible = visible.at(-1)?.key ?? "";
|
||||
// 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
|
||||
@@ -245,6 +168,43 @@ export const LogsCard: FC<{
|
||||
{/* The page heading says "Troubleshooting" now, so this card names itself — otherwise
|
||||
the log stream is the only section on the page with no label. */}
|
||||
<h2 className="text-lg font-medium">{m.logs_title()}</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.logs_sources_label()}
|
||||
</span>
|
||||
{sources.map((s) => (
|
||||
<Button
|
||||
key={s.id}
|
||||
size="sm"
|
||||
variant={s.selected ? "secondary" : "outline"}
|
||||
aria-pressed={s.selected}
|
||||
disabled={s.state === "loading"}
|
||||
onClick={() => onToggleSource(s.id)}
|
||||
>
|
||||
{s.state === "loading" && <Spinner className="mr-1 size-3.5" />}
|
||||
{s.state === "error" && (
|
||||
<AlertCircle className="mr-1 size-3.5 text-destructive" />
|
||||
)}
|
||||
{s.label}
|
||||
{s.hint && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{s.hint}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{/* The bundle list used to vanish entirely when empty, which meant the one place
|
||||
that could teach "your devices can send their logs here" showed nothing to
|
||||
anyone who had never already used it. One line is not the noise a whole empty
|
||||
card was. */}
|
||||
{devicesEmpty && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.logs_devices_empty()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((l) => (
|
||||
@@ -258,18 +218,6 @@ export const LogsCard: FC<{
|
||||
</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)}
|
||||
@@ -326,7 +274,7 @@ export const LogsCard: FC<{
|
||||
{/* 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 && (
|
||||
{error != null && rows.length > 0 && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
@@ -340,8 +288,9 @@ export const LogsCard: FC<{
|
||||
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.
|
||||
// An empty list has four quite different causes and used to render one sentence
|
||||
// for all of them: the host is quiet, the request failed, it hasn't answered yet,
|
||||
// or every source is switched off.
|
||||
<div className="p-2">
|
||||
{error ? (
|
||||
<div className="space-y-2 font-sans">
|
||||
@@ -356,36 +305,46 @@ export const LogsCard: FC<{
|
||||
<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()}
|
||||
: nothingSelected
|
||||
? m.logs_sources_none()
|
||||
: onlyPlugins
|
||||
? 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>
|
||||
visible.map((r) => (
|
||||
<div key={r.key} className="whitespace-pre-wrap break-words">
|
||||
<span className="text-muted-foreground">{fmtTime(r.ts)} </span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
LEVEL_CLASS[e.level] ?? "text-muted-foreground",
|
||||
LEVEL_CLASS[r.level] ?? "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{e.level.padEnd(5)}{" "}
|
||||
{r.level.padEnd(5)}{" "}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{e.target} </span>
|
||||
<span>{e.msg}</span>
|
||||
{/* Only device rows are tagged. Absence of a tag reads as "this host",
|
||||
and stamping every host line would double the noise in the common
|
||||
case where no bundle is loaded at all. */}
|
||||
{/* Theme-aware, unlike LEVEL_CLASS above: one fixed mid shade is legible on
|
||||
exactly one of the two palettes, and this tag is the thing that has
|
||||
to stay readable for a merged view to be worth having. Checked in
|
||||
both themes, not inferred. */}
|
||||
{r.device && (
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
[{r.device}]{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">{r.target} </span>
|
||||
<span>{r.msg}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manage}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import type { HostCheck } from "@/api/gen/model/hostCheck";
|
||||
import { checkTitle, statusLabel, worstFirst } from "@/lib/diagnostics";
|
||||
import type { Row } from "./rows";
|
||||
|
||||
/**
|
||||
* How the log entries can leave the page. Probed at runtime rather than assumed: `share` is Web
|
||||
@@ -13,18 +16,106 @@ export type ShareOutcome = "shared" | "copied" | "cancelled" | "failed";
|
||||
const MIME = "text/plain";
|
||||
|
||||
/**
|
||||
* One line per entry, in the on-screen column order but with the full date and UTC offset — a bare
|
||||
* One line per row, in the on-screen column order but with the full date and UTC offset — a bare
|
||||
* wall-clock time is ambiguous the moment the file leaves the browser, and bug reports span days.
|
||||
* A device row keeps its origin tag: in a merged export, "which machine said this" is the first
|
||||
* thing a reader needs and the last thing they can reconstruct.
|
||||
*/
|
||||
export const logsToText = (entries: LogEntry[]): string =>
|
||||
entries
|
||||
.map((e) => `${stamp(e.ts_ms)} ${e.level.padEnd(5)} ${e.target} ${e.msg}`)
|
||||
export const logsToText = (rows: Row[]): string =>
|
||||
rows
|
||||
.map(
|
||||
(r) =>
|
||||
`${stamp(r.ts)} ${r.level.padEnd(5)} ${r.device ? `[${r.device}] ` : ""}${r.target} ${r.msg}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
/** `punktfunk-logs-20260730-142231.log` — sorts chronologically in a downloads folder. */
|
||||
export const logFilename = (now: Date): string =>
|
||||
`punktfunk-logs-${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.log`;
|
||||
|
||||
/** `punktfunk-diagnostics-20260730-142231.txt` — the combined export's sibling name. */
|
||||
export const diagnosticsFilename = (now: Date): string =>
|
||||
`punktfunk-diagnostics-${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.txt`;
|
||||
|
||||
const banner = (title: string): string =>
|
||||
`\n${"=".repeat(72)}\n== ${title}\n${"=".repeat(72)}\n`;
|
||||
|
||||
/**
|
||||
* Everything this page knows, as one file: the host's health checks, the host + plugin log, and
|
||||
* every uploaded device bundle verbatim.
|
||||
*
|
||||
* **Plain text, assembled in the browser** — two deliberate calls.
|
||||
*
|
||||
* Text rather than an archive because the artifact's job is to be pasted into a bug report or
|
||||
* scrolled by the person who asked for it; a zip makes both a two-step operation and buys
|
||||
* compression nobody was short of.
|
||||
*
|
||||
* In the browser rather than from a host endpoint because the console holds *more* host log than
|
||||
* the host does: the ring is 4096 entries (`log_capture.rs`), while an open console accumulates up
|
||||
* to 5000 and keeps lines the ring has already evicted. A host-side export would quietly ship less
|
||||
* history than the page it was launched from — and it would cost a new authenticated route, a
|
||||
* regenerated OpenAPI document in both of its checked-in copies, and a row in the mgmt lane matrix.
|
||||
*
|
||||
* Device bundles go in verbatim, not re-rendered from parsed rows: a leg whose format this build
|
||||
* cannot parse must still export intact, and the raw bytes are the only version guaranteed to.
|
||||
*/
|
||||
export const diagnosticsText = (parts: {
|
||||
generatedAt: Date;
|
||||
checks: HostCheck[];
|
||||
checksUnavailable?: boolean;
|
||||
rows: Row[];
|
||||
bundles: { meta: ClientLogMeta; text: string }[];
|
||||
}): string => {
|
||||
const { generatedAt, checks, checksUnavailable, rows, bundles } = parts;
|
||||
const out: string[] = [
|
||||
`punktfunk diagnostics export`,
|
||||
`generated: ${stamp(generatedAt.getTime())}`,
|
||||
`host log lines: ${rows.length}`,
|
||||
`device bundles: ${bundles.length}`,
|
||||
];
|
||||
|
||||
out.push(banner("HEALTH CHECKS"));
|
||||
if (checksUnavailable) {
|
||||
out.push(
|
||||
"This host has no diagnostics route (it predates the checks API).",
|
||||
);
|
||||
} else if (checks.length === 0) {
|
||||
out.push("No checks reported.");
|
||||
} else {
|
||||
for (const c of worstFirst(checks)) {
|
||||
out.push(`[${statusLabel(c)}] ${checkTitle(c)} (${c.id})`);
|
||||
if (c.summary) out.push(` summary: ${c.summary}`);
|
||||
if (c.impact) out.push(` impact: ${c.impact}`);
|
||||
if (c.remedy?.text) out.push(` remedy: ${c.remedy.text}`);
|
||||
if (c.remedy?.command) out.push(` command: ${c.remedy.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(banner("HOST AND PLUGIN LOG"));
|
||||
out.push(rows.length ? logsToText(rows) : "No entries.");
|
||||
|
||||
for (const { meta, text } of bundles) {
|
||||
out.push(
|
||||
banner(
|
||||
`DEVICE BUNDLE — ${meta.device_name} (${meta.fingerprint_prefix}), received ${stamp(meta.received_ms)}`,
|
||||
),
|
||||
);
|
||||
out.push(text.trimEnd());
|
||||
}
|
||||
|
||||
// A device's clock is its own, and a bundle that looks minutes off from the host log is a
|
||||
// property of the machines, not of this file. Saying so at the bottom costs one line and saves
|
||||
// the reader from "correcting" a correlation that was never wrong.
|
||||
if (bundles.length > 0) {
|
||||
out.push(
|
||||
banner("NOTE"),
|
||||
"Device timestamps come from each device's own clock and may differ from the host's.",
|
||||
);
|
||||
}
|
||||
|
||||
return `${out.join("\n")}\n`;
|
||||
};
|
||||
|
||||
export const downloadText = (text: string, filename: string): void => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: MIME }));
|
||||
const a = document.createElement("a");
|
||||
|
||||
+189
-10
@@ -1,24 +1,203 @@
|
||||
import type { FC } from "react";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Download } from "lucide-react";
|
||||
import { type FC, useEffect, useMemo, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import { useGetDiagnostics } from "@/api/gen/diagnostics/diagnostics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { ChecksSection } from "./ChecksCard";
|
||||
import { ClientLogsSection } from "./ClientLogsCard";
|
||||
import { LogsSection } from "./LogsCard";
|
||||
import {
|
||||
detectShareMode,
|
||||
diagnosticsFilename,
|
||||
diagnosticsText,
|
||||
downloadText,
|
||||
logFilename,
|
||||
logsToText,
|
||||
type ShareMode,
|
||||
shareLogs,
|
||||
} from "./export";
|
||||
import { LogsCard, type SourceChoice } from "./LogsCard";
|
||||
import {
|
||||
deviceSource,
|
||||
HOST_SOURCE,
|
||||
hostRows,
|
||||
mergeRows,
|
||||
PLUGINS_SOURCE,
|
||||
} from "./rows";
|
||||
import { useDeviceLogs, useHostLog } from "./useLogSources";
|
||||
import { LogsView } from "./view";
|
||||
|
||||
// Troubleshooting = the host's health checks over one self-contained viewer card owning its
|
||||
// polling; this container only binds the layout. Client-uploaded bundles ("Send logs to host")
|
||||
// render beneath the live host log — same page a reporter already exports the host log from, so
|
||||
// both halves of a report live in one place.
|
||||
/** `12:04` — a chip has room for when a bundle landed, not for the date it landed on. */
|
||||
const fmtClock = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Troubleshooting: the host's health checks over one viewer that holds every log this host can
|
||||
* reach — its own, the plugin runner's, and whatever paired devices have uploaded.
|
||||
*
|
||||
* The page owns the log state rather than the viewer card, because two consumers read it: the
|
||||
* viewer, and the "export everything" action in the heading.
|
||||
*/
|
||||
export const SectionLogs: FC = () => {
|
||||
useLocale();
|
||||
|
||||
const host = useHostLog();
|
||||
const devices = useDeviceLogs();
|
||||
|
||||
// Host and plugins on by default — the page's previous "All", and still the right opening
|
||||
// state: a device bundle is a deliberate act of correlation, not something to be opted out of.
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set([HOST_SOURCE, PLUGINS_SOURCE]),
|
||||
);
|
||||
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
|
||||
const [exporting, setExporting] = 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());
|
||||
}, []);
|
||||
|
||||
// The checks are already on this page; the export reads the same cached entry rather than
|
||||
// asking the host to run every probe a second time.
|
||||
const diagnostics = useGetDiagnostics({
|
||||
query: { staleTime: 5 * 60_000, retry: false },
|
||||
});
|
||||
const checksUnsupported =
|
||||
diagnostics.error instanceof ApiError && diagnostics.error.status === 404;
|
||||
|
||||
const fromHost = useMemo(() => hostRows(host.entries), [host.entries]);
|
||||
|
||||
// Only SELECTED device bundles reach the merge. An export loads every bundle as a side effect,
|
||||
// and without this the pool would silently grow by a few thousand rows per device that nobody
|
||||
// asked to see — sorted on every poll, for nothing.
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
mergeRows([
|
||||
fromHost,
|
||||
...devices.bundles
|
||||
.filter((b) => selected.has(deviceSource(b.meta.id)))
|
||||
.map((b) => b.rows),
|
||||
]),
|
||||
[fromHost, devices.bundles, selected],
|
||||
);
|
||||
|
||||
const sources = useMemo<SourceChoice[]>(
|
||||
() => [
|
||||
{
|
||||
id: HOST_SOURCE,
|
||||
label: m.logs_source_host(),
|
||||
selected: selected.has(HOST_SOURCE),
|
||||
},
|
||||
{
|
||||
id: PLUGINS_SOURCE,
|
||||
label: m.logs_source_plugins(),
|
||||
selected: selected.has(PLUGINS_SOURCE),
|
||||
},
|
||||
...devices.bundles.map((b) => ({
|
||||
id: deviceSource(b.meta.id),
|
||||
label: b.meta.device_name,
|
||||
hint: fmtClock(b.meta.received_ms),
|
||||
selected: selected.has(deviceSource(b.meta.id)),
|
||||
state:
|
||||
b.state === "loading"
|
||||
? ("loading" as const)
|
||||
: b.state === "error"
|
||||
? ("error" as const)
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
[devices.bundles, selected],
|
||||
);
|
||||
|
||||
const onToggleSource = (id: string) => {
|
||||
const turningOn = !selected.has(id);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
// Switching a device on is what asks for its bundle — including after a failure, so a
|
||||
// second click is a retry rather than a no-op.
|
||||
if (!turningOn) return;
|
||||
const bundle = devices.bundles.find((b) => deviceSource(b.meta.id) === id);
|
||||
if (bundle && bundle.state !== "loaded" && bundle.state !== "loading") {
|
||||
void devices.load(bundle.meta);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportAll = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const bundles = await devices.loadAll();
|
||||
downloadText(
|
||||
diagnosticsText({
|
||||
generatedAt: new Date(),
|
||||
checks: diagnostics.data?.checks ?? [],
|
||||
checksUnavailable: checksUnsupported,
|
||||
// The full host buffer, not `rows` — the export is "everything", and what the
|
||||
// viewer is filtered to has no bearing on that.
|
||||
rows: fromHost,
|
||||
bundles,
|
||||
}),
|
||||
diagnosticsFilename(new Date()),
|
||||
);
|
||||
} catch {
|
||||
toast.error(m.logs_export_all_failed());
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const devicesEmpty =
|
||||
!devices.list.isLoading &&
|
||||
!devices.list.error &&
|
||||
devices.bundles.length === 0;
|
||||
|
||||
return (
|
||||
<LogsView
|
||||
checks={<ChecksSection />}
|
||||
actions={
|
||||
<Button variant="outline" onClick={onExportAll} disabled={exporting}>
|
||||
<Download className="size-4" />
|
||||
{exporting ? m.logs_export_all_working() : m.logs_export_all()}
|
||||
</Button>
|
||||
}
|
||||
viewer={
|
||||
<>
|
||||
<LogsSection />
|
||||
<ClientLogsSection />
|
||||
</>
|
||||
<LogsCard
|
||||
rows={rows}
|
||||
sources={sources}
|
||||
onToggleSource={onToggleSource}
|
||||
devicesEmpty={devicesEmpty}
|
||||
manage={
|
||||
<ClientLogsSection list={devices.list} onDeleted={devices.forget} />
|
||||
}
|
||||
follow={host.follow}
|
||||
onFollow={host.setFollow}
|
||||
onClear={host.clear}
|
||||
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={host.dropped}
|
||||
error={host.error}
|
||||
isLoading={host.isLoading}
|
||||
onRetry={() => host.refetch()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
|
||||
/**
|
||||
* One row model for every producer, so the viewer has a single timeline instead of a stream plus a
|
||||
* table of attachments.
|
||||
*
|
||||
* The insight this rests on: a client bundle is not a foreign artifact. `clients/session`'s
|
||||
* `ring_layer` formats each line as `<ISO8601-Z> <LEVEL> <target> <msg>` — the same four fields as
|
||||
* a host `LogEntry`, only serialized as text instead of JSON — and it does so in **wall clock**
|
||||
* precisely "so a bundle correlates with the host log it lands next to". Parsing it back into rows
|
||||
* is therefore recovering structure the client already had, not inventing it, and it buys the view
|
||||
* the whole feature exists for: the client's stall and the host's account of the same second, on
|
||||
* one screen.
|
||||
*/
|
||||
|
||||
/** Source ids. Devices get one each, so a chip can address a single bundle. */
|
||||
export const HOST_SOURCE = "host";
|
||||
export const PLUGINS_SOURCE = "plugins";
|
||||
export const deviceSource = (bundleId: string): string => `device:${bundleId}`;
|
||||
|
||||
/** The target prefix the host stamps on every runner-shipped line. */
|
||||
const PLUGIN_TARGET_PREFIX = "plugin:";
|
||||
|
||||
export interface Row {
|
||||
/**
|
||||
* React key. `seq` is unique within the host ring but says nothing about a bundle's lines, so
|
||||
* the moment one is loaded two rows collide and React silently drops one. Always compose the
|
||||
* source into the key.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Wall-clock ms. Host rows carry the host's clock, device rows the device's — the two can be
|
||||
* minutes apart, which is exactly why every device row is tagged with where it came from.
|
||||
*/
|
||||
ts: number;
|
||||
level: string;
|
||||
target: string;
|
||||
msg: string;
|
||||
source: string;
|
||||
/**
|
||||
* The device a row came from, rendered as the origin tag. Host and plugin rows carry none:
|
||||
* absence reads as "this host", and tagging every host line would double the noise in the
|
||||
* common case where no bundle is loaded at all.
|
||||
*/
|
||||
device?: string;
|
||||
}
|
||||
|
||||
/** Host ring entries, split onto the two producers the ring interleaves. */
|
||||
export const hostRows = (entries: LogEntry[]): Row[] =>
|
||||
entries.map((e) => {
|
||||
const source = e.target.startsWith(PLUGIN_TARGET_PREFIX)
|
||||
? PLUGINS_SOURCE
|
||||
: HOST_SOURCE;
|
||||
return {
|
||||
key: `${source}:${e.seq}`,
|
||||
ts: e.ts_ms,
|
||||
level: e.level,
|
||||
target: e.target,
|
||||
msg: e.msg,
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* `2026-08-15T12:03:47.123Z INFO punktfunk_session::stream frame late by 34ms`
|
||||
*
|
||||
* The level is written through `{:5}`, so it arrives padded and the gap before the target is one
|
||||
* or two spaces — matched loosely rather than pinned, since the padding is a formatting detail of
|
||||
* the client and not a wire contract.
|
||||
*/
|
||||
const BUNDLE_LINE =
|
||||
/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+([A-Z]{4,5})\s+(\S+)\s*([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* A bundle's text as rows.
|
||||
*
|
||||
* **Fails soft, by design.** Only the desktop session shell installs the ring layer today; the
|
||||
* Apple, Android and webOS legs are still open and will not emit this shape when they land. A line
|
||||
* that does not parse is therefore kept verbatim as its own row rather than dropped — an
|
||||
* unrecognized format degrades to "a log you can still read and search", never to a blank pane.
|
||||
* The same path carries the bundle's header line and its `… N older lines evicted …` note, which
|
||||
* are prose and were never meant to parse.
|
||||
*/
|
||||
export const bundleRows = (text: string, meta: ClientLogMeta): Row[] => {
|
||||
const source = deviceSource(meta.id);
|
||||
const rows: Row[] = [];
|
||||
// Unparsed lines inherit the timestamp of the line above so they sort beside it. Those that
|
||||
// arrive BEFORE any timestamped line (the bundle header) have nothing to inherit yet and are
|
||||
// backfilled from the first real one below — otherwise the header sorts to 1970 and the merged
|
||||
// view opens on it.
|
||||
const leading: Row[] = [];
|
||||
let lastTs: number | null = null;
|
||||
|
||||
text.split("\n").forEach((line, i) => {
|
||||
if (line.trim() === "") return;
|
||||
const key = `${source}:${i}`;
|
||||
const [, stamp, level, target, msg] = BUNDLE_LINE.exec(line) ?? [];
|
||||
const ts = stamp === undefined ? Number.NaN : Date.parse(stamp);
|
||||
|
||||
if (level !== undefined && target !== undefined && !Number.isNaN(ts)) {
|
||||
lastTs = ts;
|
||||
rows.push({
|
||||
key,
|
||||
ts,
|
||||
level,
|
||||
target,
|
||||
msg: msg ?? "",
|
||||
source,
|
||||
device: meta.device_name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const row: Row = {
|
||||
key,
|
||||
ts: lastTs ?? 0,
|
||||
level: "",
|
||||
target: "",
|
||||
msg: line,
|
||||
source,
|
||||
device: meta.device_name,
|
||||
};
|
||||
rows.push(row);
|
||||
if (lastTs === null) leading.push(row);
|
||||
});
|
||||
|
||||
// `received_ms` is the fallback for a bundle that parsed nothing at all: it is the one
|
||||
// timestamp we always have, and it puts such a bundle at the point in the timeline where it
|
||||
// actually arrived rather than at the epoch.
|
||||
const firstTs = rows.find((r) => r.ts > 0)?.ts ?? meta.received_ms;
|
||||
for (const row of leading) row.ts = firstTs;
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge pre-sorted groups onto one timeline.
|
||||
*
|
||||
* `sort` is stable (spec-required since ES2019), so rows sharing a millisecond keep their own
|
||||
* source's order instead of shuffling between polls.
|
||||
*/
|
||||
export const mergeRows = (groups: Row[][]): Row[] =>
|
||||
groups.length === 1
|
||||
? (groups[0] ?? [])
|
||||
: groups.flat().sort((a, b) => a.ts - b.ts);
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
clientLogsGet,
|
||||
useClientLogsList,
|
||||
useLogsGet,
|
||||
} from "@/api/gen/logs/logs";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { bundleRows, type Row } from "./rows";
|
||||
|
||||
const KEEP = 5_000; // accumulated entries (client memory bound)
|
||||
|
||||
/**
|
||||
* The host log poll, lifted out of the viewer card.
|
||||
*
|
||||
* It lives at page level because two things now read it: the viewer, and the page's "export
|
||||
* everything" action. A second copy of this hook would mean a second cursor and a second poll
|
||||
* racing the first over the same ring, so there is exactly one and the page passes it down.
|
||||
*
|
||||
* Cursor-paged: 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 useHostLog = () => {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [dropped, setDropped] = useState(false);
|
||||
// Set while a poll has failed and we have not yet re-read the ring from the start.
|
||||
const [resync, setResync] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setEntries([]);
|
||||
setDropped(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
entries,
|
||||
follow,
|
||||
setFollow,
|
||||
dropped,
|
||||
clear,
|
||||
error: query.error,
|
||||
isLoading: query.isLoading,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
};
|
||||
|
||||
/** A bundle plus whatever this page has managed to fetch of it. */
|
||||
export interface DeviceBundle {
|
||||
meta: ClientLogMeta;
|
||||
state: "idle" | "loading" | "loaded" | "error";
|
||||
rows: Row[];
|
||||
/** The bundle verbatim — what the combined export embeds and what a raw view would show. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uploaded client bundles: the list, and the text of the ones the operator has opened.
|
||||
*
|
||||
* Bundles load **on demand**. Each is up to 1 MiB of a device's newest ~4096 lines, and pulling
|
||||
* every one on every visit to the troubleshooting page would cost far more than it earns — most
|
||||
* visits are about the host. Clicking a device's chip is the request to merge it in.
|
||||
*/
|
||||
export const useDeviceLogs = () => {
|
||||
const list = useClientLogsList();
|
||||
const [fetched, setFetched] = useState<
|
||||
Record<string, { state: DeviceBundle["state"]; rows: Row[]; text?: string }>
|
||||
>({});
|
||||
|
||||
const metas = useMemo(() => list.data ?? [], [list.data]);
|
||||
|
||||
const load = useCallback(async (meta: ClientLogMeta) => {
|
||||
setFetched((prev) =>
|
||||
prev[meta.id]?.state === "loaded"
|
||||
? prev
|
||||
: { ...prev, [meta.id]: { state: "loading", rows: [] } },
|
||||
);
|
||||
try {
|
||||
const text = await clientLogsGet(meta.id);
|
||||
setFetched((prev) => ({
|
||||
...prev,
|
||||
[meta.id]: { state: "loaded", rows: bundleRows(text, meta), text },
|
||||
}));
|
||||
return text;
|
||||
} catch {
|
||||
setFetched((prev) => ({
|
||||
...prev,
|
||||
[meta.id]: { state: "error", rows: [] },
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Every bundle's text, fetching whatever is not in hand yet — what the combined export needs.
|
||||
* Sequential rather than parallel: bundles are capped at 1 MiB each and this runs behind an
|
||||
* explicit click, so being polite to the host beats shaving a second off a rare action.
|
||||
*/
|
||||
const loadAll = useCallback(async (): Promise<
|
||||
{ meta: ClientLogMeta; text: string }[]
|
||||
> => {
|
||||
const out: { meta: ClientLogMeta; text: string }[] = [];
|
||||
for (const meta of metas) {
|
||||
const text = await load(meta);
|
||||
if (text !== null) out.push({ meta, text });
|
||||
}
|
||||
return out;
|
||||
}, [metas, load]);
|
||||
|
||||
const bundles = useMemo<DeviceBundle[]>(
|
||||
() =>
|
||||
metas.map((meta) => ({
|
||||
meta,
|
||||
state: fetched[meta.id]?.state ?? "idle",
|
||||
rows: fetched[meta.id]?.rows ?? [],
|
||||
text: fetched[meta.id]?.text,
|
||||
})),
|
||||
[metas, fetched],
|
||||
);
|
||||
|
||||
// A deleted bundle must not keep its rows in the viewer; dropping the fetched copy is enough,
|
||||
// since `bundles` is derived from the server list.
|
||||
const forget = useCallback((id: string) => {
|
||||
setFetched((prev) => {
|
||||
if (!(id in prev)) return prev;
|
||||
const { [id]: _gone, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bundles,
|
||||
load,
|
||||
loadAll,
|
||||
forget,
|
||||
list: list as Loadable<ClientLogMeta[]>,
|
||||
};
|
||||
};
|
||||
@@ -13,18 +13,29 @@ import { m } from "@/paraglide/messages";
|
||||
* Order is deliberate: checks first (structured, actionable), the log stream underneath. When the
|
||||
* checks are green and something is still broken, the log is the natural next step — now one scroll
|
||||
* away instead of a separate destination.
|
||||
*
|
||||
* `actions` sits in the heading rather than in the viewer's toolbar because what lives there is a
|
||||
* PAGE-level export — every check, every producer, every stored bundle, regardless of what the
|
||||
* viewer is currently filtered to. The toolbar's own download means "what I am looking at"; keeping
|
||||
* the two apart in space is what keeps them apart in meaning.
|
||||
*/
|
||||
export const LogsView: FC<{ checks?: ReactNode; viewer: ReactNode }> = ({
|
||||
checks,
|
||||
viewer,
|
||||
}) => (
|
||||
export const LogsView: FC<{
|
||||
checks?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
viewer: ReactNode;
|
||||
}> = ({ checks, actions, viewer }) => (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.troubleshooting_title()}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.troubleshooting_subtitle()}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{m.troubleshooting_title()}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.troubleshooting_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
{checks}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Download } from "lucide-react";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogsCard } from "@/sections/Logs/LogsCard";
|
||||
import {
|
||||
bundleRows,
|
||||
deviceSource,
|
||||
HOST_SOURCE,
|
||||
hostRows,
|
||||
mergeRows,
|
||||
PLUGINS_SOURCE,
|
||||
} from "@/sections/Logs/rows";
|
||||
import { LogsView } from "@/sections/Logs/view";
|
||||
|
||||
const noop = () => {};
|
||||
@@ -82,6 +93,42 @@ const fixtureEntries: LogEntry[] = [
|
||||
),
|
||||
];
|
||||
|
||||
const DECK: ClientLogMeta = {
|
||||
id: "1750000000_ab12cd34ef567890_couch-deck",
|
||||
device_name: "couch-deck",
|
||||
fingerprint_prefix: "ab12cd34ef567890",
|
||||
received_ms: BASE + 12_000,
|
||||
size_bytes: 384_512,
|
||||
};
|
||||
|
||||
/**
|
||||
* A bundle exactly as `clients/session`'s ring layer writes it: a header line that does NOT parse,
|
||||
* then `<ISO8601-Z> <LEVEL> <target> <msg>`. The unparsed header is in the fixture on purpose — it
|
||||
* is the cheapest standing proof that the fail-soft path renders rather than swallows, which is
|
||||
* what the Apple/Android/webOS legs will depend on when they land with formats of their own.
|
||||
*
|
||||
* Its timestamps interleave with the host's rather than sitting after them, because interleaving is
|
||||
* the entire reason the two are in one pane.
|
||||
*/
|
||||
const iso = (seq: number) => new Date(BASE + seq * 750).toISOString();
|
||||
const deckBundle = [
|
||||
"punktfunk-session 0.4.2 (linux x86_64)",
|
||||
`${iso(4)} INFO punktfunk_session::stream connected host=skynet mode=1920x1080@60`,
|
||||
`${iso(6)} WARN punktfunk_session::audio egress late=31% — link stalled`,
|
||||
`${iso(7)} ERROR punktfunk_session::pad no rumble device for DualSense (permission denied)`,
|
||||
`${iso(9)} INFO punktfunk_session::stream decode queue drained`,
|
||||
].join("\n");
|
||||
|
||||
const hostOnly = hostRows(fixtureEntries);
|
||||
const merged = mergeRows([hostOnly, bundleRows(deckBundle, DECK)]);
|
||||
|
||||
const chip = (id: string, label: string, selected: boolean, hint?: string) => ({
|
||||
id,
|
||||
label,
|
||||
selected,
|
||||
hint,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Logs",
|
||||
component: LogsView,
|
||||
@@ -96,9 +143,23 @@ type Story = StoryObj<typeof meta>;
|
||||
// desktop (clipboard) and mobile (share sheet) affordance stay covered by the screenshot run.
|
||||
export const Following: Story = {
|
||||
args: {
|
||||
// The page-level export lives in the heading, deliberately away from the toolbar's own
|
||||
// download ("what I am looking at"). Pinned in a story so the two cannot drift back together.
|
||||
actions: (
|
||||
<Button variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export all
|
||||
</Button>
|
||||
),
|
||||
viewer: (
|
||||
<LogsCard
|
||||
entries={fixtureEntries}
|
||||
rows={hostOnly}
|
||||
sources={[
|
||||
chip(HOST_SOURCE, "Host", true),
|
||||
chip(PLUGINS_SOURCE, "Plugins", true),
|
||||
]}
|
||||
onToggleSource={noop}
|
||||
devicesEmpty
|
||||
follow
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
@@ -115,7 +176,13 @@ export const PausedWithGap: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
<LogsCard
|
||||
entries={fixtureEntries}
|
||||
rows={hostOnly}
|
||||
sources={[
|
||||
chip(HOST_SOURCE, "Host", true),
|
||||
chip(PLUGINS_SOURCE, "Plugins", true),
|
||||
]}
|
||||
onToggleSource={noop}
|
||||
devicesEmpty
|
||||
follow={false}
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
@@ -127,3 +194,62 @@ export const PausedWithGap: Story = {
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The view the multi-select exists for: the host and one device on one timeline, device lines
|
||||
* carrying their origin tag. This is the story to check when touching the merge, the tag, or the
|
||||
* chips — a regression here is invisible in the host-only stories above.
|
||||
*/
|
||||
export const HostAndDeviceMerged: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
<LogsCard
|
||||
rows={merged}
|
||||
sources={[
|
||||
chip(HOST_SOURCE, "Host", true),
|
||||
chip(PLUGINS_SOURCE, "Plugins", true),
|
||||
chip(deviceSource(DECK.id), DECK.device_name, true, "12:04"),
|
||||
]}
|
||||
onToggleSource={noop}
|
||||
follow={false}
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
onDownload={noop}
|
||||
onShare={noop}
|
||||
shareMode="copy"
|
||||
dropped={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** A device chip mid-fetch, and one whose bundle failed — both states live on the chip itself. */
|
||||
export const DeviceChipStates: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
<LogsCard
|
||||
rows={hostOnly}
|
||||
sources={[
|
||||
chip(HOST_SOURCE, "Host", true),
|
||||
chip(PLUGINS_SOURCE, "Plugins", false),
|
||||
{
|
||||
...chip(deviceSource(DECK.id), DECK.device_name, false, "12:04"),
|
||||
state: "loading" as const,
|
||||
},
|
||||
{
|
||||
...chip(deviceSource("other"), "living-room-tv", false, "09:41"),
|
||||
state: "error" as const,
|
||||
},
|
||||
]}
|
||||
onToggleSource={noop}
|
||||
follow={false}
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
onDownload={noop}
|
||||
onShare={noop}
|
||||
shareMode="copy"
|
||||
dropped={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user