diff --git a/.playwright-mcp/dees-chart-log-clean.png b/.playwright-mcp/dees-chart-log-clean.png new file mode 100644 index 0000000..9391d6d Binary files /dev/null and b/.playwright-mcp/dees-chart-log-clean.png differ diff --git a/.playwright-mcp/dees-chart-log-docker.png b/.playwright-mcp/dees-chart-log-docker.png new file mode 100644 index 0000000..bc9635d Binary files /dev/null and b/.playwright-mcp/dees-chart-log-docker.png differ diff --git a/.playwright-mcp/dees-chart-log-final.png b/.playwright-mcp/dees-chart-log-final.png new file mode 100644 index 0000000..0999227 Binary files /dev/null and b/.playwright-mcp/dees-chart-log-final.png differ diff --git a/.playwright-mcp/dees-chart-log-fixed.png b/.playwright-mcp/dees-chart-log-fixed.png new file mode 100644 index 0000000..2af1484 Binary files /dev/null and b/.playwright-mcp/dees-chart-log-fixed.png differ diff --git a/.playwright-mcp/dees-chart-log-shadow-css.png b/.playwright-mcp/dees-chart-log-shadow-css.png new file mode 100644 index 0000000..21ca090 Binary files /dev/null and b/.playwright-mcp/dees-chart-log-shadow-css.png differ diff --git a/.playwright-mcp/dees-chart-log-structured.png b/.playwright-mcp/dees-chart-log-structured.png new file mode 100644 index 0000000..7482569 Binary files /dev/null and b/.playwright-mcp/dees-chart-log-structured.png differ diff --git a/.playwright-mcp/dees-chart-log-white-boxes.png b/.playwright-mcp/dees-chart-log-white-boxes.png new file mode 100644 index 0000000..aca3af4 Binary files /dev/null and b/.playwright-mcp/dees-chart-log-white-boxes.png differ diff --git a/.playwright-mcp/filter-highlight-test.png b/.playwright-mcp/filter-highlight-test.png new file mode 100644 index 0000000..6bc54c9 Binary files /dev/null and b/.playwright-mcp/filter-highlight-test.png differ diff --git a/changelog.md b/changelog.md index 4c926bd..df2eafc 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-01-12 - 3.36.0 - feat(dees-chart-log) +add xterm search addon support and enhance chart log demo with structured/raw (Docker-like) logs and themeable styles + +- Add IXtermSearchAddon and IXtermSearchAddonBundle types and integrate xterm-addon-search loading in DeesServiceLibLoader with caching and preload support +- Expose new types in services index and add xtermAddonSearch version to CDN_VERSIONS +- Enhance dees-chart-log demo: separate structured and raw (docker) log panels, add Docker/ANSI log templates, start/stop controls for each simulation, and raw log writing +- Switch demo styling to cssManager theme-aware CSS, and import css helpers from dees-element +- Add many .playwright-mcp PNG assets used by demos/tests + ## 2026-01-12 - 3.35.1 - fix(dees-statsgrid) center CPU core bars when they occupy less than ~66% of the tile and switch bar fills to absolute positioning for correct alignment and smoother transitions diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index b7feb98..1bc2367 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.35.1', + version: '3.36.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.demo.ts b/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.demo.ts index 87ba8a2..3524ecc 100644 --- a/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.demo.ts +++ b/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.demo.ts @@ -1,16 +1,18 @@ -import { html } from '@design.estate/dees-element'; +import { html, css, cssManager } from '@design.estate/dees-element'; import type { DeesChartLog } from '../dees-chart-log/dees-chart-log.js'; import '@design.estate/dees-wcctools/demotools'; export const demoFunc = () => { return html` { - // Get the log element - const logElement = elementArg.querySelector('dees-chart-log') as DeesChartLog; - let intervalId: number; + // Get the log elements + const structuredLog = elementArg.querySelector('#structured-log') as DeesChartLog; + const rawLog = elementArg.querySelector('#raw-log') as DeesChartLog; + let structuredIntervalId: number; + let rawIntervalId: number; const serverSources = ['Server', 'Database', 'API', 'Auth', 'Cache', 'Queue', 'WebSocket', 'Scheduler']; - + const logTemplates = { debug: [ 'Loading module: {{module}}', @@ -49,14 +51,30 @@ export const demoFunc = () => { ], }; + // Docker-like raw log lines with ANSI colors + const dockerLogTemplates = [ + '\x1b[90m2024-01-15T10:23:45.123Z\x1b[0m \x1b[36mINFO\x1b[0m [nginx] GET /api/health 200 - 2ms', + '\x1b[90m2024-01-15T10:23:45.456Z\x1b[0m \x1b[33mWARN\x1b[0m [redis] Connection pool running low: 3/10', + '\x1b[90m2024-01-15T10:23:45.789Z\x1b[0m \x1b[31mERROR\x1b[0m [mongodb] Query timeout after 30000ms', + '\x1b[90m2024-01-15T10:23:46.012Z\x1b[0m \x1b[36mINFO\x1b[0m [app] Processing batch job #{{jobId}}', + '\x1b[90m2024-01-15T10:23:46.345Z\x1b[0m \x1b[32mOK\x1b[0m [health] All services healthy', + '\x1b[90m2024-01-15T10:23:46.678Z\x1b[0m \x1b[36mINFO\x1b[0m [kafka] Message consumed from topic: events', + '\x1b[90m2024-01-15T10:23:47.001Z\x1b[0m \x1b[35mDEBUG\x1b[0m [grpc] Request received: GetUser(id={{userId}})', + '\x1b[90m2024-01-15T10:23:47.234Z\x1b[0m \x1b[31mERROR\x1b[0m [auth] Token validation failed: expired', + '\x1b[90m2024-01-15T10:23:47.567Z\x1b[0m \x1b[33mWARN\x1b[0m [rate-limit] IP {{ip}} approaching rate limit', + '\x1b[90m2024-01-15T10:23:47.890Z\x1b[0m \x1b[36mINFO\x1b[0m [websocket] Client connected: session={{session}}', + // Multi-line log entry like stack traces + '\x1b[31mError: Connection refused\x1b[0m\n at TcpConnection.connect (/app/node_modules/pg/lib/connection.js:12:15)\n at Pool.connect (/app/node_modules/pg/lib/pool.js:45:23)\n at async DatabaseService.query (/app/src/db/service.ts:89:12)', + ]; + const generateRandomLog = () => { const levels: Array<'debug' | 'info' | 'warn' | 'error' | 'success'> = ['debug', 'info', 'warn', 'error', 'success']; - const weights = [0.2, 0.5, 0.15, 0.1, 0.05]; // Weighted probability - + const weights = [0.2, 0.5, 0.15, 0.1, 0.05]; + const random = Math.random(); let cumulative = 0; let level: typeof levels[0] = 'info'; - + for (let i = 0; i < weights.length; i++) { cumulative += weights[i]; if (random < cumulative) { @@ -68,7 +86,7 @@ export const demoFunc = () => { const source = serverSources[Math.floor(Math.random() * serverSources.length)]; const templates = logTemplates[level]; const template = templates[Math.floor(Math.random() * templates.length)]; - + // Replace placeholders with random values const message = template .replace('{{module}}', ['express', 'mongoose', 'redis', 'socket.io'][Math.floor(Math.random() * 4)]) @@ -92,17 +110,30 @@ export const demoFunc = () => { .replace('{{port}}', String(3000 + Math.floor(Math.random() * 10))) .replace('{{size}}', String(Math.floor(Math.random() * 500) + 100)); - logElement.addLog(level, message, source); + structuredLog.addLog(level, message, source); }; - const startSimulation = () => { - if (!intervalId) { - // Generate logs at random intervals between 500ms and 2500ms + const generateDockerLog = () => { + const template = dockerLogTemplates[Math.floor(Math.random() * dockerLogTemplates.length)]; + const now = new Date().toISOString(); + + const logLine = template + .replace(/2024-01-15T10:23:\d{2}\.\d{3}Z/g, now) + .replace('{{jobId}}', String(Math.floor(Math.random() * 10000))) + .replace('{{userId}}', String(Math.floor(Math.random() * 10000))) + .replace('{{ip}}', `192.168.1.${Math.floor(Math.random() * 255)}`) + .replace('{{session}}', Math.random().toString(36).substring(2, 11)); + + rawLog.writelnRaw(logLine); + }; + + const startStructuredSimulation = () => { + if (!structuredIntervalId) { const scheduleNext = () => { generateRandomLog(); const nextDelay = Math.random() * 2000 + 500; - intervalId = window.setTimeout(() => { - if (intervalId) { + structuredIntervalId = window.setTimeout(() => { + if (structuredIntervalId) { scheduleNext(); } }, nextDelay); @@ -111,10 +142,32 @@ export const demoFunc = () => { } }; - const stopSimulation = () => { - if (intervalId) { - window.clearTimeout(intervalId); - intervalId = null; + const stopStructuredSimulation = () => { + if (structuredIntervalId) { + window.clearTimeout(structuredIntervalId); + structuredIntervalId = null; + } + }; + + const startRawSimulation = () => { + if (!rawIntervalId) { + const scheduleNext = () => { + generateDockerLog(); + const nextDelay = Math.random() * 1000 + 200; + rawIntervalId = window.setTimeout(() => { + if (rawIntervalId) { + scheduleNext(); + } + }, nextDelay); + }; + scheduleNext(); + } + }; + + const stopRawSimulation = () => { + if (rawIntervalId) { + window.clearTimeout(rawIntervalId); + rawIntervalId = null; } }; @@ -122,49 +175,103 @@ export const demoFunc = () => { const buttons = elementArg.querySelectorAll('dees-button'); buttons.forEach(button => { const text = button.textContent?.trim(); - if (text === 'Add Single Log') { - button.addEventListener('click', () => generateRandomLog()); - } else if (text === 'Start Simulation') { - button.addEventListener('click', () => startSimulation()); - } else if (text === 'Stop Simulation') { - button.addEventListener('click', () => stopSimulation()); + switch (text) { + case 'Add Structured Log': + button.addEventListener('click', () => generateRandomLog()); + break; + case 'Start Structured': + button.addEventListener('click', () => startStructuredSimulation()); + break; + case 'Stop Structured': + button.addEventListener('click', () => stopStructuredSimulation()); + break; + case 'Add Docker Log': + button.addEventListener('click', () => generateDockerLog()); + break; + case 'Start Docker': + button.addEventListener('click', () => startRawSimulation()); + break; + case 'Stop Docker': + button.addEventListener('click', () => stopRawSimulation()); + break; } }); }}> + ${css` + .demoBox { + position: relative; + background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 5%)')}; + height: 100%; + width: 100%; + padding: 40px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 24px; + } + .section { + display: flex; + flex-direction: column; + gap: 12px; + } + .section-title { + color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; + font-size: 14px; + font-weight: 600; + font-family: 'Geist Sans', sans-serif; + } + .section-description { + color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; + font-size: 12px; + font-family: 'Geist Sans', sans-serif; + } + .controls { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + `} +
-
- Add Single Log - Start Simulation - Stop Simulation + +
+
Structured Logs (ILogEntry)
+
+ Structured log entries with level, message, and source. Supports search and keyword highlighting. +
+
+ Add Structured Log + Start Structured + Stop Structured +
+ +
+ + +
+
Raw Logs (Docker/Container Style)
+
+ Raw log output with ANSI escape sequences for real Docker/container logs. +
+
+ Add Docker Log + Start Docker + Stop Docker +
+
-
Simulating realistic server logs with various levels and sources
-
`; -}; \ No newline at end of file +}; diff --git a/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.ts b/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.ts index b8a5f1a..8d85bc0 100644 --- a/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.ts +++ b/ts_web/elements/00group-chart/dees-chart-log/dees-chart-log.ts @@ -5,13 +5,18 @@ import { 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 { @@ -26,6 +31,16 @@ export interface ILogEntry { 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; @@ -34,6 +49,9 @@ export class DeesChartLog extends DeesElement { @property() accessor label: string = 'Server Logs'; + @property({ type: String }) + accessor mode: 'structured' | 'raw' = 'structured'; + @property({ type: Array }) accessor logEntries: ILogEntry[] = []; @@ -41,27 +59,54 @@ export class DeesChartLog extends DeesElement { accessor autoScroll: boolean = true; @property({ type: Number }) - accessor maxEntries: number = 1000; + accessor maxEntries: number = 10000; - private logContainer: HTMLDivElement; + @property({ type: Array }) + accessor highlightKeywords: string[] = []; - constructor() { - super(); - domtools.elementBasic.setup(); - - } + @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` - /* TODO: Migrate hardcoded values to --dees-* CSS variables */ :host { - font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace; + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; - font-size: 12px; - line-height: 1.5; } + .mainbox { position: relative; width: 100%; @@ -76,255 +121,805 @@ export class DeesChartLog extends DeesElement { .header { background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')}; - padding: 12px 16px; + padding: 8px 12px; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; display: flex; - justify-content: space-between; 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%)')}; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + 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: 8px; + 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: 6px; - padding: 6px 12px; + 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; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .control-button:hover { - background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; - border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; + 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(0 0% 9%)', 'hsl(0 0% 93.9%)')}; - color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')}; + 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; } - .logContainer { + .terminal-container { flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 16px; - font-size: 12px; + overflow: hidden; + padding: 8px; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; } - .logEntry { - margin-bottom: 4px; - display: flex; - white-space: pre-wrap; - word-break: break-all; - font-variant-numeric: tabular-nums; + .terminal-container .xterm { + height: 100%; } - .timestamp { - color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; - margin-right: 12px; - flex-shrink: 0; - } - - .level { - margin-right: 8px; - padding: 0 6px; - border-radius: 3px; - font-weight: 600; - text-transform: uppercase; - font-size: 10px; - flex-shrink: 0; - } - - .level.debug { - color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; - background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')}; - } - - .level.info { - color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; - background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; - } - - .level.warn { - color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')}; - background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')}; - } - - .level.error { - color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')}; - background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')}; - } - - .level.success { - color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')}; - background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')}; - } - - .source { - color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; - margin-right: 8px; - flex-shrink: 0; - } - - .message { - color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')}; - flex: 1; - } - - .empty-state { + .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; } - /* Custom scrollbar */ - .logContainer::-webkit-scrollbar { + .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%; } - .logContainer::-webkit-scrollbar-track { - background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')}; + .metric.error::before { + background: hsl(0 84.2% 60.2%); } - .logContainer::-webkit-scrollbar-thumb { - background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')}; - border-radius: 4px; + .metric.warn::before { + background: hsl(25 95% 53%); } - .logContainer::-webkit-scrollbar-thumb:hover { - background: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 40%)')}; + .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.logEntries.length === 0 - ? html`
No logs to display
` - : this.logEntries.map(entry => this.renderLogEntry(entry)) - } + +
+ ${!this.terminalReady + ? html`
Loading terminal...
` + : ''}
-
- `; - } - private renderLogEntry(entry: ILogEntry): TemplateResult { - const timestamp = new Date(entry.timestamp).toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - fractionalSecondDigits: 3 - }); - - return html` -
- ${timestamp} - ${entry.level} - ${entry.source ? html`[${entry.source}]` : ''} - ${entry.message} + ${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() { - await this.domtoolsPromise; - this.logContainer = this.shadowRoot.querySelector('.logContainer'); + this.domtoolsInstance = await this.domtoolsPromise; + await this.initializeTerminal(); - // Initialize with demo server logs - const demoLogs: ILogEntry[] = [ - { timestamp: new Date().toISOString(), level: 'info', message: 'Server started on port 3000', source: 'Server' }, - { timestamp: new Date().toISOString(), level: 'debug', message: 'Loading configuration from /etc/app/config.json', source: 'Config' }, - { timestamp: new Date().toISOString(), level: 'info', message: 'Connected to MongoDB at mongodb://localhost:27017', source: 'Database' }, - { timestamp: new Date().toISOString(), level: 'success', message: 'Database connection established successfully', source: 'Database' }, - { timestamp: new Date().toISOString(), level: 'warn', message: 'No SSL certificate found, using self-signed certificate', source: 'Security' }, - { timestamp: new Date().toISOString(), level: 'info', message: 'API routes initialized: GET /api/users, POST /api/users, DELETE /api/users/:id', source: 'Router' }, - { timestamp: new Date().toISOString(), level: 'debug', message: 'Middleware stack: cors, bodyParser, authentication, errorHandler', source: 'Middleware' }, - { timestamp: new Date().toISOString(), level: 'info', message: 'WebSocket server listening on ws://localhost:3001', source: 'WebSocket' }, - ]; - - this.logEntries = demoLogs; - this.scrollToBottom(); - } - - public async updateLog(entries?: ILogEntry[]) { - if (entries) { - // Add new entries - this.logEntries = [...this.logEntries, ...entries]; - - // Trim if exceeds max entries - if (this.logEntries.length > this.maxEntries) { - this.logEntries = this.logEntries.slice(-this.maxEntries); - } - - // Trigger re-render - this.requestUpdate(); - - // Auto-scroll if enabled - await this.updateComplete; - if (this.autoScroll) { - this.scrollToBottom(); + // Process any initial log entries + if (this.logEntries.length > 0) { + for (const entry of this.logEntries) { + this.writeLogEntry(entry); } } } - public clearLogs() { - this.logEntries = []; - this.requestUpdate(); + 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 scrollToBottom() { - if (this.logContainer) { - this.logContainer.scrollTop = this.logContainer.scrollHeight; + 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 newEntry: ILogEntry = { + const entry: ILogEntry = { timestamp: new Date().toISOString(), level, message, - source + source, }; - this.updateLog([newEntry]); + + // 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(); + } } } diff --git a/ts_web/services/DeesServiceLibLoader.ts b/ts_web/services/DeesServiceLibLoader.ts index f16e498..94c1295 100644 --- a/ts_web/services/DeesServiceLibLoader.ts +++ b/ts_web/services/DeesServiceLibLoader.ts @@ -25,6 +25,24 @@ export interface IXtermFitAddonBundle { FitAddon: typeof FitAddon; } +/** + * Bundle type for xterm-addon-search + * SearchAddon is loaded from CDN, so we use a minimal interface + */ +export interface IXtermSearchAddonBundle { + SearchAddon: new () => IXtermSearchAddon; +} + +/** + * Minimal interface for xterm SearchAddon + */ +export interface IXtermSearchAddon { + activate(terminal: Terminal): void; + dispose(): void; + findNext(term: string, searchOptions?: { regex?: boolean; wholeWord?: boolean; caseSensitive?: boolean; incremental?: boolean }): boolean; + findPrevious(term: string, searchOptions?: { regex?: boolean; wholeWord?: boolean; caseSensitive?: boolean; incremental?: boolean }): boolean; +} + /** * Bundle type for Tiptap editor and extensions */ @@ -56,6 +74,7 @@ export class DeesServiceLibLoader { // Cached library references private xtermLib: IXtermBundle | null = null; private xtermFitAddonLib: IXtermFitAddonBundle | null = null; + private xtermSearchAddonLib: IXtermSearchAddonBundle | null = null; private highlightJsLib: HLJSApi | null = null; private apexChartsLib: typeof ApexChartsType | null = null; private tiptapLib: ITiptapBundle | null = null; @@ -63,6 +82,7 @@ export class DeesServiceLibLoader { // Loading promises to prevent duplicate concurrent loads private xtermLoadingPromise: Promise | null = null; private xtermFitAddonLoadingPromise: Promise | null = null; + private xtermSearchAddonLoadingPromise: Promise | null = null; private highlightJsLoadingPromise: Promise | null = null; private apexChartsLoadingPromise: Promise | null = null; private tiptapLoadingPromise: Promise | null = null; @@ -134,6 +154,32 @@ export class DeesServiceLibLoader { return this.xtermFitAddonLoadingPromise; } + /** + * Load xterm-addon-search from CDN + * @returns Promise resolving to SearchAddon class + */ + public async loadXtermSearchAddon(): Promise { + if (this.xtermSearchAddonLib) { + return this.xtermSearchAddonLib; + } + + if (this.xtermSearchAddonLoadingPromise) { + return this.xtermSearchAddonLoadingPromise; + } + + this.xtermSearchAddonLoadingPromise = (async () => { + const url = `${CDN_BASE}/xterm-addon-search@${CDN_VERSIONS.xtermAddonSearch}/+esm`; + const module = await import(/* @vite-ignore */ url); + + this.xtermSearchAddonLib = { + SearchAddon: module.SearchAddon, + }; + return this.xtermSearchAddonLib; + })(); + + return this.xtermSearchAddonLoadingPromise; + } + /** * Inject xterm CSS styles into the document head */ @@ -257,6 +303,7 @@ export class DeesServiceLibLoader { await Promise.all([ this.loadXterm(), this.loadXtermFitAddon(), + this.loadXtermSearchAddon(), this.loadHighlightJs(), this.loadApexCharts(), this.loadTiptap(), @@ -266,12 +313,14 @@ export class DeesServiceLibLoader { /** * Check if a specific library is already loaded */ - public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'highlightJs' | 'apexCharts' | 'tiptap'): boolean { + public isLoaded(library: 'xterm' | 'xtermFitAddon' | 'xtermSearchAddon' | 'highlightJs' | 'apexCharts' | 'tiptap'): boolean { switch (library) { case 'xterm': return this.xtermLib !== null; case 'xtermFitAddon': return this.xtermFitAddonLib !== null; + case 'xtermSearchAddon': + return this.xtermSearchAddonLib !== null; case 'highlightJs': return this.highlightJsLib !== null; case 'apexCharts': diff --git a/ts_web/services/index.ts b/ts_web/services/index.ts index 63ea0ed..fa2e6cd 100644 --- a/ts_web/services/index.ts +++ b/ts_web/services/index.ts @@ -1,3 +1,3 @@ export { DeesServiceLibLoader } from './DeesServiceLibLoader.js'; -export type { IXtermBundle, IXtermFitAddonBundle, ITiptapBundle } from './DeesServiceLibLoader.js'; +export type { IXtermBundle, IXtermFitAddonBundle, IXtermSearchAddonBundle, IXtermSearchAddon, ITiptapBundle } from './DeesServiceLibLoader.js'; export { CDN_BASE, CDN_VERSIONS } from './versions.js'; diff --git a/ts_web/services/versions.ts b/ts_web/services/versions.ts index e92ecf9..33cce41 100644 --- a/ts_web/services/versions.ts +++ b/ts_web/services/versions.ts @@ -5,6 +5,7 @@ export const CDN_VERSIONS = { xterm: '5.3.0', xtermAddonFit: '0.8.0', + xtermAddonSearch: '0.13.0', highlightJs: '11.11.1', apexcharts: '5.3.6', tiptap: '2.23.0',