src/lib/logger/log-viewer.ts
File Relationships
Architecture violations
- [warning] max-cyclomatic-complexity: 'createLogViewer' has cyclomatic complexity 20 (max 10)
- [warning] max-cyclomatic-complexity: 'getLogViewerStyles' has cyclomatic complexity 19 (max 10)
- [warning] max-lines: 'createLogViewer' has 240 lines (max 80)
- [warning] max-lines: 'render' has 120 lines (max 80)
- [warning] max-lines: 'getLogViewerStyles' has 198 lines (max 80)
Symbols by Kind
function
11
interface
2
method
2
type
1
All Symbols
| Name | Kind | Visibility | Status | Lines | Signature |
|---|---|---|---|---|---|
| LogViewerVariant | type | exported- | 19-19 | type LogViewerVariant |
|
| LogViewerOptions | interface | exported- | 21-26 | interface LogViewerOptions |
|
| LogViewer | interface | exported- | 28-33 | interface LogViewer |
|
| refresh | method | - | 30-30 | refresh(): : Promise<void> |
|
| dispose | method | - | 32-32 | dispose(): : void |
|
| createLogViewer | function | exported- | 54-293 | createLogViewer(options: LogViewerOptions): : LogViewer |
|
| formatEntriesAsText | function | - | 63-67 | formatEntriesAsText(entries: LogEntry[]): : string |
|
| copyToClipboard | function | - | 69-83 | copyToClipboard(text: string): : Promise<void> |
|
| downloadAsJson | function | - | 85-94 | downloadAsJson(entries: LogEntry[]): : void |
|
| filterEntries | function | - | 96-123 | filterEntries(): : LogEntry[] |
|
| formatTime | function | - | 125-132 | formatTime(ts: string): : string |
|
| renderEntries | function | - | 134-151 | renderEntries(entries: LogEntry[]): : string |
|
| render | function | - | 153-272 | render(): : void |
|
| refresh | function | - | 274-277 | refresh(): : Promise<void> |
|
| dispose | function | - | 279-284 | dispose(): : void |
|
| getLogViewerStyles | function | exported- | 299-496 | getLogViewerStyles(variant: LogViewerVariant): : string |
Full Source
/**
* Reusable Log Viewer Component
*
* Renders log entries with level filter (debug/info/warn/error) and text search.
* Works in any extension context (content script, devtools, options page).
*
* Usage:
* const viewer = createLogViewer({ container, variant });
* viewer.refresh(); // load entries from log store
* viewer.dispose(); // unsubscribe from updates
*/
import { debounce } from "@/lib/shared/functions";
import type { LogLevel, LogEntry } from "./index";
import { loadLogEntries, clearLogEntries, onLogUpdate } from "./log-store";
import { escapeHtml } from "@/lib/ui/html-utils";
import { ac } from "node_modules/@faker-js/faker/dist/airline-Dz1uGqgJ";
export type LogViewerVariant = "panel" | "devtools" | "options";
export interface LogViewerOptions {
/** Container element to render into */
container: HTMLElement;
/** Visual variant (affects class prefix and CSS expectations) */
variant: LogViewerVariant;
}
export interface LogViewer {
/** Re-render with current entries from the store */
refresh(): Promise<void>;
/** Unsubscribe from real-time updates */
dispose(): void;
}
// Map LogLevel to display CSS class
const LEVEL_CSS: Record<string, string> = {
debug: "debug",
info: "info",
warn: "warn",
error: "error",
audit: "audit",
};
// Display labels for filter buttons
const LEVEL_LABELS: Record<string, string> = {
all: "All",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
audit: "Audit",
};
export function createLogViewer(options: LogViewerOptions): LogViewer {
const { container, variant } = options;
let activeFilter: LogLevel | "all" = "all";
let searchQuery = "";
let timeFrom = "";
let timeTo = "";
let allEntries: LogEntry[] = [];
let unsubscribe: (() => void) | null = null;
function formatEntriesAsText(entries: LogEntry[]): string {
return entries
.map((e) => `[${e.ts}] [${e.level.toUpperCase()}] [${e.ns}] ${e.msg}`)
.join("\n");
}
async function copyToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for contexts where clipboard API is restricted
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
}
function downloadAsJson(entries: LogEntry[]): void {
const json = JSON.stringify(entries, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `fill-all-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
function filterEntries(): LogEntry[] {
let filtered = allEntries;
if (activeFilter !== "all") {
filtered = filtered.filter((e) => e.level === activeFilter);
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(e) =>
e.msg.toLowerCase().includes(q) || e.ns.toLowerCase().includes(q),
);
}
if (timeFrom) {
const fromMs = new Date(timeFrom).getTime();
filtered = filtered.filter((e) => new Date(e.ts).getTime() >= fromMs);
}
if (timeTo) {
// Add 59s 999ms to include the full minute selected
const toMs = new Date(timeTo).getTime() + 59999;
filtered = filtered.filter((e) => new Date(e.ts).getTime() <= toMs);
}
return filtered;
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleTimeString("pt-BR");
} catch {
return ts;
}
}
function renderEntries(entries: LogEntry[]): string {
if (entries.length === 0) {
return `<div class="lv-empty">Nenhum log encontrado.</div>`;
}
return entries
.map(
(entry, idx) => `
<div class="lv-entry lv-${LEVEL_CSS[entry.level] ?? "info"}">
<span class="lv-time">${formatTime(entry.ts)}</span>
<span class="lv-level">${entry.level.toUpperCase()}</span>
<span class="lv-ns">${escapeHtml(entry.ns)}</span>
<span class="lv-msg">${escapeHtml(entry.msg)}</span>
<button class="lv-copy-entry-btn" data-idx="${idx}" title="Copiar entrada">📋</button>
</div>`,
)
.join("");
}
function render(): void {
const filtered = filterEntries();
const filterBtns = (
["all", "debug", "info", "warn", "error", "audit"] as const
)
.map(
(level) =>
`<button class="lv-filter-btn${activeFilter === level ? " active" : ""}" data-level="${level}">${LEVEL_LABELS[level]}</button>`,
)
.join("");
container.innerHTML = `
<div class="lv-toolbar">
<div class="lv-filters">${filterBtns}</div>
<input class="lv-search" type="text" placeholder="Buscar logs..." value="${escapeHtml(searchQuery)}" />
<input class="lv-time-from" type="datetime-local" title="De:" value="${timeFrom}" />
<input class="lv-time-to" type="datetime-local" title="Até:" value="${timeTo}" />
<button class="lv-copy-all-btn" title="Copiar todos os logs visíveis">📋</button>
<button class="lv-download-json-btn" title="Baixar logs como JSON">⬇️</button>
<button class="lv-clear-btn" title="Limpar todos os logs">🗑️</button>
<span class="lv-count">${filtered.length}/${allEntries.length}</span>
</div>
<div class="lv-entries">${renderEntries(filtered)}</div>
`;
// Bind filter buttons
container
.querySelectorAll<HTMLButtonElement>(".lv-filter-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
activeFilter = btn.dataset.level as LogLevel | "all";
render();
});
});
// Bind search input
const searchInput = container.querySelector<HTMLInputElement>(".lv-search");
const actualLen = searchInput?.value.length ?? 0;
if (searchInput) {
searchInput.addEventListener(
"input",
debounce(() => {
searchQuery = searchInput.value;
render();
}, 300),
);
const newInput = container.querySelector<HTMLInputElement>(".lv-search");
if (newInput) {
const len =
newInput.value.length > actualLen ? newInput.value.length : actualLen;
newInput.focus();
newInput.setSelectionRange(len, len);
}
}
// Bind time-range inputs
const timeFromInput =
container.querySelector<HTMLInputElement>(".lv-time-from");
if (timeFromInput) {
timeFromInput.addEventListener("change", () => {
timeFrom = timeFromInput.value;
render();
});
}
const timeToInput =
container.querySelector<HTMLInputElement>(".lv-time-to");
if (timeToInput) {
timeToInput.addEventListener("change", () => {
timeTo = timeToInput.value;
render();
});
}
// Bind copy-all button
container
.querySelector(".lv-copy-all-btn")
?.addEventListener("click", () => {
const filtered = filterEntries();
const text = formatEntriesAsText(filtered);
void copyToClipboard(text);
});
// Bind download JSON button
container
.querySelector(".lv-download-json-btn")
?.addEventListener("click", () => {
downloadAsJson(filterEntries());
});
// Bind per-entry copy buttons
container
.querySelectorAll<HTMLButtonElement>(".lv-copy-entry-btn")
.forEach((btn) => {
btn.addEventListener("click", () => {
const idx = Number(btn.dataset.idx);
const filtered = filterEntries();
const entry = filtered[idx];
if (entry) {
const text = formatEntriesAsText([entry]);
void copyToClipboard(text);
}
});
});
// Bind clear button
container
.querySelector(".lv-clear-btn")
?.addEventListener("click", async () => {
await clearLogEntries();
allEntries = [];
render();
});
// Auto-scroll to bottom
const entriesEl = container.querySelector(".lv-entries");
if (entriesEl) {
entriesEl.scrollTop = entriesEl.scrollHeight;
}
}
async function refresh(): Promise<void> {
allEntries = await loadLogEntries();
render();
}
function dispose(): void {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
}
// Subscribe to real-time updates
unsubscribe = onLogUpdate((entries) => {
allEntries = entries;
render();
});
return { refresh, dispose };
}
/**
* Returns CSS for the log viewer. Call once and inject into the page/shadow DOM.
* @param variant - affects scoping/colors
*/
export function getLogViewerStyles(variant: LogViewerVariant): string {
const isDark = variant === "panel" || variant === "devtools";
const bg = isDark ? "#0f172a" : "#ffffff";
const text = isDark ? "#cbd5e1" : "#1e293b";
const muted = isDark ? "#475569" : "#64748b";
const border = isDark ? "#1e293b" : "#e2e8f0";
const hoverBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.03)";
const filterBg = isDark ? "#1e293b" : "#f1f5f9";
const filterActive = isDark ? "#4f46e5" : "#4f46e5";
const inputBg = isDark ? "#1e293b" : "#ffffff";
const inputBorder = isDark ? "#334155" : "#e2e8f0";
return `
.lv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
flex-shrink: 0;
}
.lv-filters {
display: flex;
gap: 4px;
}
.lv-filter-btn {
padding: 3px 10px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${filterBg};
color: ${text};
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.lv-filter-btn:hover {
border-color: ${filterActive};
color: ${filterActive};
}
.lv-filter-btn.active {
background: ${filterActive};
color: #fff;
border-color: ${filterActive};
}
.lv-search {
flex: 1;
min-width: 120px;
padding: 4px 8px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${inputBg};
color: ${text};
font-size: 12px;
outline: none;
}
.lv-search:focus {
border-color: ${filterActive};
}
.lv-clear-btn {
padding: 3px 8px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${filterBg};
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.lv-clear-btn:hover {
border-color: #dc2626;
}
.lv-count {
font-size: 11px;
color: ${muted};
white-space: nowrap;
}
.lv-entries {
flex: 1;
overflow-y: auto;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
background: ${bg};
border: 1px solid ${border};
border-radius: 4px;
max-height: 500px;
}
.lv-entry {
display: flex;
gap: 8px;
padding: 3px 8px;
border-bottom: 1px solid ${border};
align-items: baseline;
}
.lv-entry:last-child {
border-bottom: none;
}
.lv-entry:hover {
background: ${hoverBg};
}
.lv-time {
color: ${muted};
flex-shrink: 0;
min-width: 65px;
}
.lv-level {
flex-shrink: 0;
min-width: 40px;
font-weight: 600;
font-size: 10px;
}
.lv-ns {
color: ${muted};
flex-shrink: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lv-msg {
color: ${text};
word-break: break-word;
}
.lv-empty {
text-align: center;
padding: 32px;
color: ${muted};
font-size: 13px;
}
/* Level colors */
.lv-debug .lv-level { color: ${muted}; }
.lv-info .lv-level { color: ${isDark ? "#a5b4fc" : "#4f46e5"}; }
.lv-info .lv-msg { color: ${isDark ? "#a5b4fc" : "#4f46e5"}; }
.lv-warn .lv-level { color: ${isDark ? "#fbbf24" : "#d97706"}; }
.lv-warn .lv-msg { color: ${isDark ? "#fbbf24" : "#d97706"}; }
.lv-error .lv-level { color: ${isDark ? "#f87171" : "#dc2626"}; }
.lv-error .lv-msg { color: ${isDark ? "#f87171" : "#dc2626"}; }
.lv-audit .lv-level { color: ${isDark ? "#c084fc" : "#9333ea"}; }
.lv-audit .lv-msg { color: ${isDark ? "#c084fc" : "#9333ea"}; }
/* time-range inputs */
.lv-time-from, .lv-time-to {
padding: 4px 6px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${inputBg};
color: ${text};
font-size: 11px;
outline: none;
}
.lv-time-from:focus, .lv-time-to:focus {
border-color: ${filterActive};
}
/* Download JSON button */
.lv-download-json-btn {
padding: 3px 8px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${filterBg};
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.lv-download-json-btn:hover {
border-color: ${filterActive};
color: ${filterActive};
}
/* Copy buttons */
.lv-copy-all-btn {
padding: 3px 8px;
border: 1px solid ${inputBorder};
border-radius: 4px;
background: ${filterBg};
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.lv-copy-all-btn:hover {
border-color: ${filterActive};
color: ${filterActive};
}
.lv-copy-entry-btn {
flex-shrink: 0;
margin-left: auto;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
font-size: 11px;
opacity: 0;
transition: opacity 0.15s;
}
.lv-entry:hover .lv-copy-entry-btn {
opacity: 0.6;
}
.lv-copy-entry-btn:hover {
opacity: 1 !important;
}
`;
}