feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions) one dialog drafts the issue: title, description, the steps drafted from the session's markers and page changes (a marker without a label is named by its time), the environment (version, browser, system, window, language). Beside it, what goes with it: the browser and server logs — each reviewable line by line, an unticked line is not sent — and the screenshots, removable, with paste / drop / pick to add more. - it saves as it goes (debug_sessions.draft, migration 0090): the rendered markdown in the user's language, the excluded rows, and its own fields to reopen as it was; a sent session is read-only - the zip export (issue.md, client.log, server.log, screenshots) is built from the saved draft by app/issues/bundle.py — the one definition the Gitea delivery (S4) will share; .log, not .jsonl, which Gitea refuses - the log line's level tags and clock move to components/logLineParts so the review list draws a level the way both log viewers do
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
|
||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
|
||||
import { closeComposer } from "../lib/debugRecorder";
|
||||
import {
|
||||
draftSteps,
|
||||
environment,
|
||||
renderIssue,
|
||||
type DraftForm,
|
||||
type DraftStep,
|
||||
type IssueText,
|
||||
} from "../lib/issueDraft";
|
||||
import { moduleDef } from "../lib/modules";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
|
||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
|
||||
// to add more). It saves as it goes, so closing it loses nothing: Settings → Diagnosztika reopens
|
||||
// any past session. The export and (S4) the issue are built from what it SAVED.
|
||||
|
||||
const SAVE_DELAY_MS = 800;
|
||||
type Source = "web" | "server";
|
||||
const sourceOf = (row: ServerLogRow): Source => (row.source === "web" ? "web" : "server");
|
||||
|
||||
export default function IssueComposer({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const sessionQ = useQuery({ queryKey: qk.debugSession(id), queryFn: () => api.debugSession(id) });
|
||||
const eventsQ = useQuery({
|
||||
queryKey: qk.debugSessionEvents(id),
|
||||
queryFn: () => api.debugSessionEvents(id),
|
||||
});
|
||||
const failed = sessionQ.isError || eventsQ.isError;
|
||||
return (
|
||||
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
|
||||
{failed && !(sessionQ.data && eventsQ.data) ? (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
onRetry={() => {
|
||||
void sessionQ.refetch();
|
||||
void eventsQ.refetch();
|
||||
}}
|
||||
>
|
||||
{t("recorder.composer.loadFailed")}
|
||||
</StateMessage>
|
||||
) : !sessionQ.data || !eventsQ.data ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<ComposerBody
|
||||
session={sessionQ.data}
|
||||
rows={eventsQ.data.items}
|
||||
total={eventsQ.data.total}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The saved form if it has the shape this composer writes; otherwise null (an older or foreign
|
||||
* draft starts over from the session rather than half-applying). */
|
||||
function savedForm(session: DebugSession): DraftForm | null {
|
||||
const form = session.draft?.form as Partial<DraftForm> | undefined;
|
||||
if (!form || typeof form.title !== "string" || !Array.isArray(form.steps)) return null;
|
||||
return {
|
||||
title: form.title,
|
||||
description: typeof form.description === "string" ? form.description : "",
|
||||
steps: form.steps as DraftStep[],
|
||||
excluded: Array.isArray(form.excluded) ? (form.excluded as number[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function ComposerBody({
|
||||
session,
|
||||
rows,
|
||||
total,
|
||||
}: {
|
||||
session: DebugSession;
|
||||
rows: ServerLogRow[];
|
||||
total: number;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const pageName = (page: string) => {
|
||||
const def = moduleDef(page as Page);
|
||||
return def ? t(def.labelKey) : page;
|
||||
};
|
||||
const [form, setForm] = useState<DraftForm>(
|
||||
() =>
|
||||
savedForm(session) ?? {
|
||||
title: "",
|
||||
description: "",
|
||||
steps: draftSteps(rows, pageName, logClock),
|
||||
excluded: [],
|
||||
},
|
||||
);
|
||||
const [reviewing, setReviewing] = useState<Source | null>(null);
|
||||
const sent = session.issue_url !== null;
|
||||
|
||||
const shotsQ = useQuery({
|
||||
queryKey: qk.debugScreenshots(session.id),
|
||||
queryFn: () => api.debugScreenshots(session.id),
|
||||
});
|
||||
const shots = shotsQ.data?.items ?? [];
|
||||
|
||||
const excluded = useMemo(() => new Set(form.excluded), [form.excluded]);
|
||||
const kept = (source: Source) =>
|
||||
rows.filter((r) => sourceOf(r) === source && !excluded.has(r.id)).length;
|
||||
const env = useMemo(() => environment(i18n.language), [i18n.language]);
|
||||
|
||||
const text: IssueText = {
|
||||
steps: t("recorder.issue.steps"),
|
||||
environment: t("recorder.issue.environment"),
|
||||
envKeys: Object.fromEntries(env.map(([k]) => [k, t(`recorder.composer.env.${k}`)])),
|
||||
screenshot: t("recorder.issue.screenshot"),
|
||||
attachments: t("recorder.issue.attachments"),
|
||||
clientLog: (count) => t("recorder.issue.clientLog", { count }),
|
||||
serverLog: (count) => t("recorder.issue.serverLog", { count }),
|
||||
screenshots: (count) => t("recorder.issue.screenshots", { count }),
|
||||
truncated:
|
||||
total > rows.length
|
||||
? t("recorder.composer.truncated", { kept: rows.length, total })
|
||||
: undefined,
|
||||
};
|
||||
const body = renderIssue(
|
||||
form,
|
||||
env,
|
||||
{ client: kept("web"), server: kept("server"), screenshots: shots.length },
|
||||
text,
|
||||
);
|
||||
|
||||
// Saves as it goes — after the typing pauses, not on every key.
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.saveDebugDraft(session.id, {
|
||||
title: form.title.trim(),
|
||||
body,
|
||||
excluded: form.excluded,
|
||||
form: form as unknown as Record<string, unknown>,
|
||||
}),
|
||||
meta: { failTitle: "recorder.composer.saveFailed" },
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
||||
},
|
||||
});
|
||||
const dirty = useRef(false);
|
||||
const saveRef = useRef(save.mutate);
|
||||
saveRef.current = save.mutate;
|
||||
useEffect(() => {
|
||||
if (sent || !dirty.current) return;
|
||||
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [body, form.title, sent]);
|
||||
const edit = (next: (f: DraftForm) => DraftForm) => {
|
||||
dirty.current = true;
|
||||
setForm(next);
|
||||
};
|
||||
|
||||
// The export is built from the SAVED draft: save what is on screen first.
|
||||
const exportZip = async () => {
|
||||
if (!sent) await save.mutateAsync();
|
||||
const a = document.createElement("a");
|
||||
a.href = api.debugExportUrl(session.id);
|
||||
a.download = "";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const started = new Date(session.started_at);
|
||||
const minutes = Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
(Date.parse(session.ended_at ?? new Date().toISOString()) - started.getTime()) / 60_000,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
{t("recorder.composer.meta", {
|
||||
start: started.toLocaleString(i18n.language),
|
||||
minutes,
|
||||
level: t(`settings.diagnostics.levels.${session.level}`),
|
||||
})}
|
||||
</p>
|
||||
{sent && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
|
||||
<span>{t("recorder.composer.sent")}</span>
|
||||
<a
|
||||
href={session.issue_url!}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline"
|
||||
>
|
||||
{t("recorder.composer.openIssue")}
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{reviewing ? (
|
||||
<ReviewList
|
||||
rows={rows.filter((r) => sourceOf(r) === reviewing)}
|
||||
name={t(
|
||||
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
|
||||
)}
|
||||
excluded={excluded}
|
||||
readOnly={sent}
|
||||
onToggle={(rowId) =>
|
||||
edit((f) => ({
|
||||
...f,
|
||||
excluded: f.excluded.includes(rowId)
|
||||
? f.excluded.filter((x) => x !== rowId)
|
||||
: [...f.excluded, rowId],
|
||||
}))
|
||||
}
|
||||
onDone={() => setReviewing(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<DraftFields form={form} edit={edit} env={env} readOnly={sent} />
|
||||
<Attachments
|
||||
session={session}
|
||||
shots={shots}
|
||||
counts={{
|
||||
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
|
||||
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
|
||||
}}
|
||||
readOnly={sent}
|
||||
onReview={setReviewing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<span className="mr-auto text-xs text-muted" aria-live="polite">
|
||||
{save.isPending
|
||||
? t("recorder.composer.saving")
|
||||
: save.isSuccess
|
||||
? t("recorder.composer.saved")
|
||||
: ""}
|
||||
</span>
|
||||
<Button variant="ghost" onClick={closeComposer}>
|
||||
{t("recorder.composer.close")}
|
||||
</Button>
|
||||
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
|
||||
{t("recorder.composer.export")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftFields({
|
||||
form,
|
||||
edit,
|
||||
env,
|
||||
readOnly,
|
||||
}: {
|
||||
form: DraftForm;
|
||||
edit: (next: (f: DraftForm) => DraftForm) => void;
|
||||
env: [string, string][];
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setStep = (i: number, patch: Partial<DraftStep>) =>
|
||||
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||
return (
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="issue-title" className="text-sm text-muted">
|
||||
{t("recorder.composer.fieldTitle")}
|
||||
</label>
|
||||
<input
|
||||
id="issue-title"
|
||||
value={form.title}
|
||||
readOnly={readOnly}
|
||||
maxLength={200}
|
||||
placeholder={t("recorder.composer.titlePlaceholder")}
|
||||
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="issue-description" className="text-sm text-muted">
|
||||
{t("recorder.composer.description")}
|
||||
</label>
|
||||
<textarea
|
||||
id="issue-description"
|
||||
value={form.description}
|
||||
readOnly={readOnly}
|
||||
rows={4}
|
||||
placeholder={t("recorder.composer.descriptionPlaceholder")}
|
||||
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
|
||||
className={`${inputCls} resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-1.5 min-w-0">
|
||||
<legend className="mb-1.5 text-sm text-muted">{t("recorder.composer.steps")}</legend>
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{form.steps.map((step, i) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 rounded-lg bg-surface/60 pl-2.5 pr-1 py-1"
|
||||
>
|
||||
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<input
|
||||
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
|
||||
value={step.text}
|
||||
readOnly={readOnly}
|
||||
placeholder={t("recorder.composer.stepPlaceholder")}
|
||||
onChange={(e) => setStep(i, { text: e.target.value })}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
|
||||
/>
|
||||
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
|
||||
{t(`recorder.composer.stepKind.${step.kind}`)}
|
||||
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
iconOnly
|
||||
icon={<X className="w-4 h-4" />}
|
||||
label={t("recorder.composer.removeStep", { n: i + 1 })}
|
||||
onClick={() => edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }))}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="self-start"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() =>
|
||||
edit((f) => ({
|
||||
...f,
|
||||
steps: [...f.steps, { id: `m${Date.now()}`, kind: "manual", text: "" }],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("recorder.composer.addStep")}
|
||||
</Button>
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-muted">{t("recorder.composer.environment")}</div>
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm">
|
||||
{env.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted">{t(`recorder.composer.env.${k}`)}</dt>
|
||||
<dd className="break-words tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
||||
|
||||
function Attachments({
|
||||
session,
|
||||
shots,
|
||||
counts,
|
||||
readOnly,
|
||||
onReview,
|
||||
}: {
|
||||
session: DebugSession;
|
||||
shots: DebugScreenshot[];
|
||||
counts: Record<Source, [kept: number, total: number]>;
|
||||
readOnly: boolean;
|
||||
onReview: (source: Source) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(session.id) });
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id) });
|
||||
};
|
||||
const add = useMutation({
|
||||
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const addFiles = (files: Iterable<File>) => {
|
||||
for (const file of files) if (IMAGE_TYPES.includes(file.type)) add.mutate(file);
|
||||
};
|
||||
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const images = [...e.clipboardData.files].filter((f) => IMAGE_TYPES.includes(f.type));
|
||||
if (!images.length) return;
|
||||
e.preventDefault();
|
||||
for (const image of images) add.mutate(image);
|
||||
};
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const logRow = (source: Source, icon: React.ReactNode) => {
|
||||
const [kept, total] = counts[source];
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm">
|
||||
{t(source === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog")}
|
||||
</span>
|
||||
<span className="text-xs text-muted tabular-nums">
|
||||
{kept === total
|
||||
? t("recorder.composer.lines", { count: total })
|
||||
: t("recorder.composer.linesKept", { kept, total })}
|
||||
{` · ${source === "web" ? "client.log" : "server.log"}`}
|
||||
</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => onReview(source)}>
|
||||
{t("recorder.composer.review")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"
|
||||
onPaste={readOnly ? undefined : onPaste}
|
||||
>
|
||||
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
|
||||
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||
{shots.map((shot, i) => (
|
||||
<div
|
||||
key={shot.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
|
||||
>
|
||||
<img
|
||||
src={api.debugScreenshotUrl(session.id, shot.id)}
|
||||
alt={t("recorder.composer.screenshot", { n: i + 1 })}
|
||||
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
|
||||
<span className="text-xs text-muted tabular-nums">
|
||||
{Math.round(shot.size / 1024)} KB
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
iconOnly
|
||||
icon={<X className="w-4 h-4" />}
|
||||
label={t("recorder.composer.removeShot", { n: i + 1 })}
|
||||
loading={remove.isPending && remove.variables === shot.id}
|
||||
onClick={() => remove.mutate(shot.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
|
||||
>
|
||||
{t("recorder.composer.drop")}{" "}
|
||||
<label className="cursor-pointer font-semibold text-accent hover:underline">
|
||||
{t("recorder.composer.pick")}
|
||||
<input
|
||||
type="file"
|
||||
accept={IMAGE_TYPES.join(",")}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One log, line by line: an unticked line is not sent. */
|
||||
function ReviewList({
|
||||
rows,
|
||||
name,
|
||||
excluded,
|
||||
readOnly,
|
||||
onToggle,
|
||||
onDone,
|
||||
}: {
|
||||
rows: ServerLogRow[];
|
||||
name: string;
|
||||
excluded: Set<number>;
|
||||
readOnly: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
|
||||
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={onDone}>
|
||||
{t("recorder.composer.reviewDone")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
|
||||
{rows.map((row) => {
|
||||
const tag = LEVEL_TAG[row.level];
|
||||
const off = excluded.has(row.id);
|
||||
return (
|
||||
<li key={row.id} className="border-b border-border/50 last:border-b-0">
|
||||
<label
|
||||
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
|
||||
off ? "opacity-50 line-through" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!off}
|
||||
disabled={readOnly}
|
||||
aria-label={t("recorder.composer.keepRow", {
|
||||
event: row.event ?? "",
|
||||
msg: row.msg,
|
||||
})}
|
||||
onChange={() => onToggle(row.id)}
|
||||
/>
|
||||
<span className="tabular-nums text-muted">{logClock(row.ts)}</span>
|
||||
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
||||
<span className="truncate">{row.event}</span>
|
||||
<span className="col-span-4 truncate sm:col-span-1">{row.msg}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user