926 lines
26 KiB
TypeScript
926 lines
26 KiB
TypeScript
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<typeof setInterval> | 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`
|
|
<div class="mainbox">
|
|
<div class="header">
|
|
<div class="title">${this.label}</div>
|
|
<div class="search-box">
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs..."
|
|
.value=${this.searchQuery}
|
|
@input=${(e: InputEvent) => this.handleSearchInput(e)}
|
|
@keydown=${(e: KeyboardEvent) => this.handleSearchKeydown(e)}
|
|
/>
|
|
<div class="search-nav">
|
|
<button @click=${() => this.searchPrevious()} title="Previous match">↑</button>
|
|
<button @click=${() => this.searchNext()} title="Next match">↓</button>
|
|
</div>
|
|
<button
|
|
class="filter-toggle ${this.filterMode ? 'active' : ''}"
|
|
@click=${() => this.toggleFilterMode()}
|
|
title="${this.filterMode ? 'Switch to highlight mode' : 'Switch to filter mode'}"
|
|
>
|
|
${this.filterMode ? 'Filter' : 'Highlight'}
|
|
</button>
|
|
</div>
|
|
<div class="controls">
|
|
<button
|
|
class="control-button ${this.autoScroll ? 'active' : ''}"
|
|
@click=${() => this.toggleAutoScroll()}
|
|
>
|
|
Auto Scroll
|
|
</button>
|
|
<button class="control-button" @click=${() => this.clearLogs()}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="terminal-container">
|
|
${!this.terminalReady
|
|
? html`<div class="loading-state">Loading terminal...</div>`
|
|
: ''}
|
|
</div>
|
|
|
|
${this.showMetrics
|
|
? html`
|
|
<div class="metrics-bar">
|
|
<span class="metric error">errors: ${this.metrics.error}</span>
|
|
<span class="metric warn">warns: ${this.metrics.warn}</span>
|
|
<span class="metric info">info: ${this.metrics.info}</span>
|
|
<span class="metric success">success: ${this.metrics.success}</span>
|
|
<span class="metric debug">debug: ${this.metrics.debug}</span>
|
|
<span class="metric rate">${this.metrics.rate.toFixed(1)} logs/sec</span>
|
|
</div>
|
|
`
|
|
: ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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<void> {
|
|
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<ILogEntry['level'], string> = {
|
|
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();
|
|
}
|
|
}
|
|
}
|