feat(logs): coalesce repeated identical lines into one row (×N badge)

A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-10 08:29:43 +02:00
parent e2d5105da2
commit 51b160bfc9
5 changed files with 137 additions and 7 deletions
+18 -1
View File
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
// Storm-coalesced row: the server folds repeated identical lines into one row and
// counts them in context._repeat (first occurrence kept in _firstAt).
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
return (
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
@@ -38,7 +42,20 @@ function LogRow({ log }: { log: AppLogRecord }) {
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
<span className="truncate text-term-text">{log.message}</span>
<span className="truncate text-term-text">
{repeat != null && repeat > 1 && (
<span
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
title={t("logs.repeated", {
count: repeat,
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
})}
>
×{repeat}
</span>
)}
{log.message}
</span>
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
</button>
{open && hasDetail && (
+1
View File
@@ -947,6 +947,7 @@ export const en: Catalog = {
status: "Status",
path: "Path",
empty: "No logs.",
repeated: "Repeated {{count}} times (first at {{firstAt}})",
},
backup: {
title: "Backup",
+1
View File
@@ -963,6 +963,7 @@ export const sq = {
status: "Statusi",
path: "Rruga",
empty: "Asnjë regjistër.",
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
},
backup: {
title: "Kopje rezervë",