import { DeesElement, css, cssManager, customElement, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { demoFunc } from './dees-chart-log.demo.js'; import { themeDefaultStyles } from '../../00theme.js'; import { DeesServiceLibLoader, type IXtermSearchAddon, CDN_BASE, CDN_VERSIONS } from '../../../services/index.js'; // Type imports (no runtime overhead) import type { Terminal } from 'xterm'; import type { FitAddon } from 'xterm-addon-fit'; declare global { interface HTMLElementTagNameMap { 'dees-chart-log': DeesChartLog; } } export interface ILogEntry { timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string; } export interface ILogMetrics { debug: number; info: number; warn: number; error: number; success: number; total: number; rate: number; // logs per second (rolling average) } @customElement('dees-chart-log') export class DeesChartLog extends DeesElement { public static demo = demoFunc; public static demoGroup = 'Chart'; @property() accessor label: string = 'Server Logs'; @property({ type: String }) accessor mode: 'structured' | 'raw' = 'structured'; @property({ type: Array }) accessor logEntries: ILogEntry[] = []; @property({ type: Boolean }) accessor autoScroll: boolean = true; @property({ type: Number }) accessor maxEntries: number = 10000; @property({ type: Array }) accessor highlightKeywords: string[] = []; @property({ type: Boolean }) accessor showMetrics: boolean = true; @state() accessor searchQuery: string = ''; @state() accessor filterMode: boolean = false; @state() accessor metrics: ILogMetrics = { debug: 0, info: 0, warn: 0, error: 0, success: 0, total: 0, rate: 0 }; @state() accessor terminalReady: boolean = false; // Buffer of all log entries for filter mode private logBuffer: ILogEntry[] = []; // Track trailing hidden entries count for live updates in filter mode private trailingHiddenCount: number = 0; // xterm instances private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; private searchAddon: IXtermSearchAddon | null = null; private resizeObserver: ResizeObserver | null = null; private terminalThemeSubscription: any = null; private domtoolsInstance: any = null; // Rate calculation private rateBuffer: number[] = []; private rateInterval: ReturnType | null = null; public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` :host { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; } .mainbox { position: relative; width: 100%; height: 400px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; } .header { background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')}; padding: 8px 12px; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; display: flex; align-items: center; gap: 12px; flex-shrink: 0; flex-wrap: wrap; } .title { font-weight: 500; font-size: 14px; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; white-space: nowrap; } .search-box { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 150px; max-width: 300px; } .search-box input { flex: 1; padding: 4px 8px; font-size: 12px; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 4px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; outline: none; } .search-box input:focus { border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; } .search-box input::placeholder { color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; } .search-nav { display: flex; gap: 2px; } .search-nav button { padding: 4px 6px; font-size: 11px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 3px; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; cursor: pointer; line-height: 1; } .search-nav button:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')}; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .filter-toggle { padding: 4px 8px; font-size: 11px; font-weight: 500; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 4px; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; cursor: pointer; transition: all 0.15s; white-space: nowrap; } .filter-toggle:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')}; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .filter-toggle.active { background: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 47%)')}; border-color: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 47%)')}; color: hsl(0 0% 9%); } .controls { display: flex; gap: 6px; margin-left: auto; } .control-button { background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 4px; padding: 4px 10px; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; } .control-button:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 25%)')}; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .control-button.active { background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; color: white; } .terminal-container { flex: 1; overflow: hidden; padding: 8px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; } .terminal-container .xterm { height: 100%; } .loading-state { display: flex; align-items: center; justify-content: center; height: 100%; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; font-style: italic; font-size: 13px; } .metrics-bar { background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')}; border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; padding: 6px 12px; display: flex; gap: 16px; font-size: 11px; font-weight: 500; flex-shrink: 0; } .metric { display: flex; align-items: center; gap: 4px; } .metric::before { content: ''; width: 8px; height: 8px; border-radius: 50%; } .metric.error::before { background: hsl(0 84.2% 60.2%); } .metric.warn::before { background: hsl(25 95% 53%); } .metric.info::before { background: hsl(222.2 47.4% 51.2%); } .metric.success::before { background: hsl(142.1 76.2% 36.3%); } .metric.debug::before { background: hsl(0 0% 63.9%); } .metric.rate { margin-left: auto; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; } .metric.rate::before { display: none; } `, ]; constructor() { super(); domtools.elementBasic.setup(); } public render(): TemplateResult { return html`
${this.label}
${!this.terminalReady ? html`
Loading terminal...
` : ''}
${this.showMetrics ? html`
errors: ${this.metrics.error} warns: ${this.metrics.warn} info: ${this.metrics.info} success: ${this.metrics.success} debug: ${this.metrics.debug} ${this.metrics.rate.toFixed(1)} logs/sec
` : ''}
`; } public async firstUpdated() { this.domtoolsInstance = await this.domtoolsPromise; await this.initializeTerminal(); // Process any initial log entries if (this.logEntries.length > 0) { for (const entry of this.logEntries) { this.writeLogEntry(entry); } } } private async initializeTerminal() { const libLoader = DeesServiceLibLoader.getInstance(); const [xtermBundle, fitBundle, searchBundle] = await Promise.all([ libLoader.loadXterm(), libLoader.loadXtermFitAddon(), libLoader.loadXtermSearchAddon(), ]); // Inject xterm CSS into shadow root (needed because shadow DOM doesn't inherit from document.head) await this.injectXtermStylesIntoShadow(); this.terminal = new xtermBundle.Terminal({ cursorBlink: false, disableStdin: true, fontSize: 12, fontFamily: "'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace", theme: this.getTerminalTheme(), scrollback: this.maxEntries, convertEol: true, }); this.fitAddon = new fitBundle.FitAddon(); this.searchAddon = new searchBundle.SearchAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.searchAddon); const container = this.shadowRoot!.querySelector('.terminal-container') as HTMLElement; this.terminal.open(container); // Fit after a small delay to ensure proper sizing await new Promise((resolve) => requestAnimationFrame(resolve)); this.fitAddon.fit(); // Set up resize observer this.resizeObserver = new ResizeObserver(() => { this.fitAddon?.fit(); }); this.resizeObserver.observe(container); // Subscribe to theme changes this.terminalThemeSubscription = this.domtoolsInstance.themeManager.themeObservable.subscribe(() => { if (this.terminal) { this.terminal.options.theme = this.getTerminalTheme(); } }); // Start rate calculation interval this.rateInterval = setInterval(() => this.calculateRate(), 1000); this.terminalReady = true; } private getTerminalTheme() { const isDark = this.domtoolsInstance?.themeManager?.isDarkMode ?? true; return isDark ? { background: '#0a0a0a', foreground: '#e0e0e0', cursor: '#e0e0e0', selectionBackground: '#404040', black: '#000000', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c', blue: '#6272a4', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2', brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94', brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df', brightCyan: '#a4ffff', brightWhite: '#ffffff', } : { background: '#ffffff', foreground: '#333333', cursor: '#333333', selectionBackground: '#add6ff', black: '#000000', red: '#cd3131', green: '#00bc00', yellow: '#949800', blue: '#0451a5', magenta: '#bc05bc', cyan: '#0598bc', white: '#555555', brightBlack: '#666666', brightRed: '#cd3131', brightGreen: '#14ce14', brightYellow: '#b5ba00', brightBlue: '#0451a5', brightMagenta: '#bc05bc', brightCyan: '#0598bc', brightWhite: '#a5a5a5', }; } /** * Inject xterm CSS styles into shadow root * This is needed because shadow DOM doesn't inherit styles from document.head */ private async injectXtermStylesIntoShadow(): Promise { const styleId = 'xterm-shadow-styles'; if (this.shadowRoot!.getElementById(styleId)) { return; // Already injected } const cssUrl = `${CDN_BASE}/xterm@${CDN_VERSIONS.xterm}/css/xterm.css`; const response = await fetch(cssUrl); const cssText = await response.text(); const style = document.createElement('style'); style.id = styleId; style.textContent = cssText; this.shadowRoot!.appendChild(style); } // ===================== // Structured Log Methods // ===================== /** * Add a single structured log entry */ public addLog(level: ILogEntry['level'], message: string, source?: string) { const entry: ILogEntry = { timestamp: new Date().toISOString(), level, message, source, }; // Add to buffer this.logBuffer.push(entry); if (this.logBuffer.length > this.maxEntries) { this.logBuffer.shift(); } // Handle display based on filter mode if (!this.filterMode || !this.searchQuery) { // No filtering - show all entries this.writeLogEntry(entry); } else if (this.entryMatchesFilter(entry)) { // Entry matches filter - reset trailing count and write entry this.trailingHiddenCount = 0; this.writeLogEntry(entry); } else { // Entry doesn't match - update trailing placeholder this.updateTrailingPlaceholder(); } this.updateMetrics(entry.level); } /** * Add multiple structured log entries */ public updateLog(entries?: ILogEntry[]) { if (!entries) return; for (const entry of entries) { // Add to buffer this.logBuffer.push(entry); if (this.logBuffer.length > this.maxEntries) { this.logBuffer.shift(); } // Handle display based on filter mode if (!this.filterMode || !this.searchQuery) { // No filtering - show all entries this.writeLogEntry(entry); } else if (this.entryMatchesFilter(entry)) { // Entry matches filter - reset trailing count and write entry this.trailingHiddenCount = 0; this.writeLogEntry(entry); } else { // Entry doesn't match - update trailing placeholder this.updateTrailingPlaceholder(); } this.updateMetrics(entry.level); } } /** * Update the trailing hidden placeholder in real-time * Clears the last line if a placeholder already exists, then writes updated count */ private updateTrailingPlaceholder() { if (!this.terminal) return; if (this.trailingHiddenCount > 0) { // Clear the previous placeholder line (move up, clear line, move to start) this.terminal.write('\x1b[1A\x1b[2K\r'); } this.trailingHiddenCount++; this.writeHiddenPlaceholder(this.trailingHiddenCount); if (this.autoScroll) { this.terminal.scrollToBottom(); } } /** * Check if a log entry matches the current filter */ private entryMatchesFilter(entry: ILogEntry): boolean { if (!this.searchQuery) return true; const query = this.searchQuery.toLowerCase(); return ( entry.message.toLowerCase().includes(query) || entry.level.toLowerCase().includes(query) || (entry.source?.toLowerCase().includes(query) ?? false) ); } private writeLogEntry(entry: ILogEntry) { if (!this.terminal) return; const formatted = this.formatLogEntry(entry); this.terminal.writeln(formatted); if (this.autoScroll) { this.terminal.scrollToBottom(); } } private formatLogEntry(entry: ILogEntry): string { const timestamp = this.formatTimestamp(entry.timestamp); const levelColors: Record = { debug: '\x1b[90m', // Gray info: '\x1b[36m', // Cyan warn: '\x1b[33m', // Yellow error: '\x1b[31m', // Red success: '\x1b[32m', // Green }; const reset = '\x1b[0m'; const dim = '\x1b[2m'; const levelStr = `${levelColors[entry.level]}[${entry.level.toUpperCase().padEnd(7)}]${reset}`; const sourceStr = entry.source ? `${dim}[${entry.source}]${reset} ` : ''; const messageStr = this.applyHighlights(entry.message); return `${dim}${timestamp}${reset} ${levelStr} ${sourceStr}${messageStr}`; } private formatTimestamp(isoString: string): string { const date = new Date(isoString); return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, } as Intl.DateTimeFormatOptions); } private applyHighlights(text: string): string { // Collect all keywords to highlight const keywords = [...this.highlightKeywords]; // In filter mode, also highlight the search query if (this.filterMode && this.searchQuery) { keywords.push(this.searchQuery); } if (keywords.length === 0) return text; let result = text; for (const keyword of keywords) { // Escape regex special characters const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escaped})`, 'gi'); // Yellow background, black text for highlights result = result.replace(regex, '\x1b[43m\x1b[30m$1\x1b[0m'); } return result; } // ===================== // Raw Log Methods // ===================== /** * Write raw data to the terminal (for Docker logs, etc.) */ public writeRaw(data: string) { if (!this.terminal) return; this.terminal.write(data); this.recordLogEvent(); if (this.autoScroll) { this.terminal.scrollToBottom(); } } /** * Write a raw line to the terminal */ public writelnRaw(line: string) { if (!this.terminal) return; this.terminal.writeln(line); this.recordLogEvent(); if (this.autoScroll) { this.terminal.scrollToBottom(); } } // ===================== // Search Methods // ===================== private handleSearchInput(e: InputEvent) { const input = e.target as HTMLInputElement; const newQuery = input.value; const queryChanged = this.searchQuery !== newQuery; this.searchQuery = newQuery; if (this.filterMode && queryChanged) { // Re-render with filtered logs this.reRenderFilteredLogs(); } else if (this.searchQuery) { // Just highlight/search in current view this.searchAddon?.findNext(this.searchQuery); } } private handleSearchKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { if (e.shiftKey) { this.searchPrevious(); } else { this.searchNext(); } } else if (e.key === 'Escape') { this.searchQuery = ''; (e.target as HTMLInputElement).value = ''; } } /** * Search for a query in the terminal */ public search(query: string): void { this.searchQuery = query; this.searchAddon?.findNext(query); } /** * Find next search match */ public searchNext(): void { if (this.searchQuery) { this.searchAddon?.findNext(this.searchQuery); } } /** * Find previous search match */ public searchPrevious(): void { if (this.searchQuery) { this.searchAddon?.findPrevious(this.searchQuery); } } // ===================== // Control Methods // ===================== private toggleAutoScroll() { this.autoScroll = !this.autoScroll; if (this.autoScroll && this.terminal) { this.terminal.scrollToBottom(); } } /** * Toggle between filter mode and highlight mode */ private toggleFilterMode() { this.filterMode = !this.filterMode; this.reRenderFilteredLogs(); } /** * Re-render logs based on current filter state * In filter mode: show matching logs with placeholders for hidden entries * In highlight mode: show all logs */ private reRenderFilteredLogs() { if (!this.terminal) return; // Clear terminal and re-render this.terminal.clear(); // Reset trailing count for fresh render this.trailingHiddenCount = 0; if (!this.filterMode || !this.searchQuery) { // No filtering - show all entries for (const entry of this.logBuffer) { const formatted = this.formatLogEntry(entry); this.terminal.writeln(formatted); } } else { // Filter mode with placeholders for hidden entries let hiddenCount = 0; for (const entry of this.logBuffer) { if (this.entryMatchesFilter(entry)) { // Output placeholder for hidden entries if any if (hiddenCount > 0) { this.writeHiddenPlaceholder(hiddenCount); hiddenCount = 0; } // Output the matching entry const formatted = this.formatLogEntry(entry); this.terminal.writeln(formatted); } else { hiddenCount++; } } // Handle trailing hidden entries if (hiddenCount > 0) { this.writeHiddenPlaceholder(hiddenCount); // Store trailing count for live updates this.trailingHiddenCount = hiddenCount; } } if (this.autoScroll) { this.terminal.scrollToBottom(); } } /** * Write a placeholder line showing how many log entries are hidden by filter */ private writeHiddenPlaceholder(count: number) { const dim = '\x1b[2m'; const reset = '\x1b[0m'; const text = count === 1 ? `[1 log line hidden by filter ...]` : `[${count} log lines hidden by filter ...]`; this.terminal?.writeln(`${dim}${text}${reset}`); } /** * Clear all logs and reset metrics */ public clearLogs() { this.terminal?.clear(); this.logBuffer = []; this.trailingHiddenCount = 0; this.resetMetrics(); } /** * Scroll to the bottom of the log */ public scrollToBottom() { this.terminal?.scrollToBottom(); } // ===================== // Metrics Methods // ===================== private updateMetrics(level: ILogEntry['level']) { this.metrics = { ...this.metrics, [level]: this.metrics[level] + 1, total: this.metrics.total + 1, }; this.recordLogEvent(); } private recordLogEvent() { this.rateBuffer.push(Date.now()); } private calculateRate() { const now = Date.now(); // Keep only events from the last 10 seconds this.rateBuffer = this.rateBuffer.filter((t) => now - t < 10000); const rate = this.rateBuffer.length / 10; if (rate !== this.metrics.rate) { this.metrics = { ...this.metrics, rate }; } } private resetMetrics() { this.metrics = { debug: 0, info: 0, warn: 0, error: 0, success: 0, total: 0, rate: 0 }; this.rateBuffer = []; } // ===================== // Lifecycle // ===================== async disconnectedCallback() { await super.disconnectedCallback(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.terminalThemeSubscription) { this.terminalThemeSubscription.unsubscribe(); } if (this.rateInterval) { clearInterval(this.rateInterval); } if (this.terminal) { this.terminal.dispose(); } } }