src/lib/logger/log-viewer.ts

Total Symbols
16
Lines of Code
497
Avg Complexity
4.5
Avg Coverage
97.6%

File Relationships

graph LR renderEntries["renderEntries"] formatTime["formatTime"] render["render"] filterEntries["filterEntries"] formatEntriesAsText["formatEntriesAsText"] copyToClipboard["copyToClipboard"] downloadAsJson["downloadAsJson"] refresh["refresh"] createLogViewer["createLogViewer"] renderEntries -->|calls| formatTime render -->|calls| filterEntries render -->|calls| renderEntries render -->|calls| render render -->|calls| formatEntriesAsText render -->|calls| copyToClipboard render -->|calls| downloadAsJson refresh -->|calls| render createLogViewer -->|calls| render click renderEntries "../symbols/a2c9a34c1a76387d.html" click formatTime "../symbols/92e172acedf85054.html" click render "../symbols/80ece8fdb820f2e0.html" click filterEntries "../symbols/0ad485f0d0788dd9.html" click formatEntriesAsText "../symbols/9512b6f4ff5fc5a0.html" click copyToClipboard "../symbols/a758a4a218f84aaf.html" click downloadAsJson "../symbols/b031a65522b26156.html" click refresh "../symbols/f0f8b893cd35303a.html" click createLogViewer "../symbols/cc8b77ecd2282660.html"

Architecture violations

View all

  • [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;
    }
  `;
}