import { html, nothing } from "lit" ;
import { t } from "../../i18n/index.ts" ;
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts" ;
import type { LogEntry, LogLevel } from "../types.ts" ;
const LEVELS: LogLevel[] = ["trace" , "debug" , "info" , "warn" , "error" , "fatal" ];
export type LogsProps = {
loading: boolean ;
error: string | null ;
file: string | null ;
entries: LogEntry[];
filterText: string;
levelFilters: Record<LogLevel, boolean >;
autoFollow: boolean ;
truncated: boolean ;
onFilterTextChange: (next: string) => void ;
onLevelToggle: (level: LogLevel, enabled: boolean ) => void ;
onToggleAutoFollow: (next: boolean ) => void ;
onRefresh: () => void ;
onExport: (lines: string[], label: string) => void ;
onScroll: (event: Event) => void ;
};
function formatTime(value?: string | null ) {
if (!value) {
return "" ;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleTimeString();
}
function matchesFilter(entry: LogEntry, needle: string) {
if (!needle) {
return true ;
}
const haystack = normalizeLowercaseStringOrEmpty(
[entry.message, entry.subsystem, entry.raw].filter(Boolean ).join(" " ),
);
return haystack.includes(needle);
}
export function renderLogs(props: LogsProps) {
const needle = normalizeLowercaseStringOrEmpty(props.filterText);
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
const filtered = props.entries.filter((entry) => {
if (entry.level && !props.levelFilters[entry.level]) {
return false ;
}
return matchesFilter(entry, needle);
});
const exportLabel = needle || levelFiltered ? "filtered" : "visible" ;
return html`
<section class ="card" >
<div class ="row" style="justify-content: space-between;" >
<div>
<div class ="card-title" >Logs</div>
<div class ="card-sub" >Gateway file logs (JSONL).</div>
</div>
<div class ="row" style="gap: 8px;" >
<button class ="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? t("common.loading" ) : t("common.refresh" )}
</button>
<button
class ="btn"
?disabled=${filtered.length === 0 }
@click=${() =>
props.onExport(
filtered.map((entry) => entry.raw),
exportLabel,
)}
>
Export ${exportLabel}
</button>
</div>
</div>
<div class ="filters" style="margin-top: 14px;" >
<label class ="field" style="min-width: 220px;" >
<span>Filter</span>
<input
.value=${props.filterText}
@input=${(e: Event) => props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs"
/>
</label>
<label class ="field checkbox" >
<span>Auto-follow</span>
<input
type="checkbox"
.checked=${props.autoFollow}
@change=${(e: Event) =>
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)}
/>
</label>
</div>
<div class ="chip-row" style="margin-top: 12px;" >
${LEVELS.map(
(level) => html`
<label class ="chip log-chip ${level}" >
<input
type="checkbox"
.checked=${props.levelFilters[level]}
@change=${(e: Event) =>
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
/>
<span>${level}</span>
</label>
`,
)}
</div>
${props.file
? html`<div class ="muted" style="margin-top: 10px;" >File: ${props.file}</div>`
: nothing}
${props.truncated
? html`
<div class ="callout" style="margin-top: 10px" >
Log output truncated; showing latest chunk.
</div>
`
: nothing}
${props.error
? html`<div class ="callout danger" style="margin-top: 10px;" >${props.error}</div>`
: nothing}
<div class ="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${filtered.length === 0
? html` <div class ="muted" style="padding: 12px" >No log entries.</div> `
: filtered.map(
(entry) => html`
<div class ="log-row" >
<div class ="log-time mono" >${formatTime(entry.time)}</div>
<div class ="log-level ${entry.level ?? " "}" >${entry.level ?? "" }</div>
<div class ="log-subsystem mono" >${entry.subsystem ?? "" }</div>
<div class ="log-message mono" >${entry.message ?? entry.raw}</div>
</div>
`,
)}
</div>
</section>
`;
}
Messung V0.5 in Prozent C=99 H=99 G=98
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland