@design.estate/dees-catalog 3.35.1 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,18 @@ import {
5
5
  customElement,
6
6
  html,
7
7
  property,
8
+ state,
8
9
  type TemplateResult,
9
10
  } from '@design.estate/dees-element';
10
11
 
11
12
  import * as domtools from '@design.estate/dees-domtools';
12
13
  import { demoFunc } from './dees-chart-log.demo.js';
13
14
  import { themeDefaultStyles } from '../../00theme.js';
15
+ import { DeesServiceLibLoader, type IXtermSearchAddon, CDN_BASE, CDN_VERSIONS } from '../../../services/index.js';
14
16
 
17
+ // Type imports (no runtime overhead)
18
+ import type { Terminal } from 'xterm';
19
+ import type { FitAddon } from 'xterm-addon-fit';
15
20
 
16
21
  declare global {
17
22
  interface HTMLElementTagNameMap {
@@ -26,6 +31,16 @@ export interface ILogEntry {
26
31
  source?: string;
27
32
  }
28
33
 
34
+ export interface ILogMetrics {
35
+ debug: number;
36
+ info: number;
37
+ warn: number;
38
+ error: number;
39
+ success: number;
40
+ total: number;
41
+ rate: number; // logs per second (rolling average)
42
+ }
43
+
29
44
  @customElement('dees-chart-log')
30
45
  export class DeesChartLog extends DeesElement {
31
46
  public static demo = demoFunc;
@@ -34,6 +49,9 @@ export class DeesChartLog extends DeesElement {
34
49
  @property()
35
50
  accessor label: string = 'Server Logs';
36
51
 
52
+ @property({ type: String })
53
+ accessor mode: 'structured' | 'raw' = 'structured';
54
+
37
55
  @property({ type: Array })
38
56
  accessor logEntries: ILogEntry[] = [];
39
57
 
@@ -41,27 +59,54 @@ export class DeesChartLog extends DeesElement {
41
59
  accessor autoScroll: boolean = true;
42
60
 
43
61
  @property({ type: Number })
44
- accessor maxEntries: number = 1000;
62
+ accessor maxEntries: number = 10000;
45
63
 
46
- private logContainer: HTMLDivElement;
64
+ @property({ type: Array })
65
+ accessor highlightKeywords: string[] = [];
47
66
 
48
- constructor() {
49
- super();
50
- domtools.elementBasic.setup();
51
-
52
- }
67
+ @property({ type: Boolean })
68
+ accessor showMetrics: boolean = true;
69
+
70
+ @state()
71
+ accessor searchQuery: string = '';
72
+
73
+ @state()
74
+ accessor filterMode: boolean = false;
75
+
76
+ @state()
77
+ accessor metrics: ILogMetrics = { debug: 0, info: 0, warn: 0, error: 0, success: 0, total: 0, rate: 0 };
78
+
79
+ @state()
80
+ accessor terminalReady: boolean = false;
81
+
82
+ // Buffer of all log entries for filter mode
83
+ private logBuffer: ILogEntry[] = [];
84
+
85
+ // Track trailing hidden entries count for live updates in filter mode
86
+ private trailingHiddenCount: number = 0;
87
+
88
+ // xterm instances
89
+ private terminal: Terminal | null = null;
90
+ private fitAddon: FitAddon | null = null;
91
+ private searchAddon: IXtermSearchAddon | null = null;
92
+ private resizeObserver: ResizeObserver | null = null;
93
+ private terminalThemeSubscription: any = null;
94
+ private domtoolsInstance: any = null;
95
+
96
+ // Rate calculation
97
+ private rateBuffer: number[] = [];
98
+ private rateInterval: ReturnType<typeof setInterval> | null = null;
53
99
 
54
100
  public static styles = [
55
101
  themeDefaultStyles,
56
102
  cssManager.defaultStyles,
57
103
  css`
58
- /* TODO: Migrate hardcoded values to --dees-* CSS variables */
59
104
  :host {
60
- font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
105
+ display: block;
106
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
61
107
  color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
62
- font-size: 12px;
63
- line-height: 1.5;
64
108
  }
109
+
65
110
  .mainbox {
66
111
  position: relative;
67
112
  width: 100%;
@@ -76,255 +121,805 @@ export class DeesChartLog extends DeesElement {
76
121
 
77
122
  .header {
78
123
  background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
79
- padding: 12px 16px;
124
+ padding: 8px 12px;
80
125
  border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
81
126
  display: flex;
82
- justify-content: space-between;
83
127
  align-items: center;
128
+ gap: 12px;
84
129
  flex-shrink: 0;
130
+ flex-wrap: wrap;
85
131
  }
86
132
 
87
133
  .title {
88
134
  font-weight: 500;
89
135
  font-size: 14px;
90
136
  color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
91
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
137
+ white-space: nowrap;
92
138
  }
93
139
 
94
- .controls {
140
+ .search-box {
95
141
  display: flex;
96
- gap: 8px;
142
+ align-items: center;
143
+ gap: 4px;
144
+ flex: 1;
145
+ min-width: 150px;
146
+ max-width: 300px;
97
147
  }
98
148
 
99
- .control-button {
100
- background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
101
- border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
102
- border-radius: 6px;
103
- padding: 6px 12px;
104
- color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
105
- cursor: pointer;
149
+ .search-box input {
150
+ flex: 1;
151
+ padding: 4px 8px;
106
152
  font-size: 12px;
107
- font-weight: 500;
108
- transition: all 0.15s;
109
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
110
- }
111
-
112
- .control-button:hover {
113
- background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
114
- border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
115
- color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
153
+ border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
154
+ border-radius: 4px;
155
+ background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
156
+ color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
157
+ outline: none;
116
158
  }
117
159
 
118
- .control-button.active {
119
- background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 93.9%)')};
120
- color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
160
+ .search-box input:focus {
161
+ border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
121
162
  }
122
163
 
123
- .logContainer {
124
- flex: 1;
125
- overflow-y: auto;
126
- overflow-x: hidden;
127
- padding: 16px;
128
- font-size: 12px;
164
+ .search-box input::placeholder {
165
+ color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
129
166
  }
130
167
 
131
- .logEntry {
132
- margin-bottom: 4px;
168
+ .search-nav {
133
169
  display: flex;
134
- white-space: pre-wrap;
135
- word-break: break-all;
136
- font-variant-numeric: tabular-nums;
170
+ gap: 2px;
137
171
  }
138
172
 
139
- .timestamp {
140
- color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
141
- margin-right: 12px;
142
- flex-shrink: 0;
173
+ .search-nav button {
174
+ padding: 4px 6px;
175
+ font-size: 11px;
176
+ background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
177
+ border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
178
+ border-radius: 3px;
179
+ color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
180
+ cursor: pointer;
181
+ line-height: 1;
143
182
  }
144
183
 
145
- .level {
146
- margin-right: 8px;
147
- padding: 0 6px;
148
- border-radius: 3px;
149
- font-weight: 600;
150
- text-transform: uppercase;
151
- font-size: 10px;
152
- flex-shrink: 0;
184
+ .search-nav button:hover {
185
+ background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')};
186
+ color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
153
187
  }
154
188
 
155
- .level.debug {
189
+ .filter-toggle {
190
+ padding: 4px 8px;
191
+ font-size: 11px;
192
+ font-weight: 500;
193
+ background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
194
+ border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
195
+ border-radius: 4px;
156
196
  color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
157
- background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')};
197
+ cursor: pointer;
198
+ transition: all 0.15s;
199
+ white-space: nowrap;
158
200
  }
159
201
 
160
- .level.info {
161
- color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
162
- background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
202
+ .filter-toggle:hover {
203
+ background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')};
204
+ color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
163
205
  }
164
206
 
165
- .level.warn {
166
- color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
167
- background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
207
+ .filter-toggle.active {
208
+ background: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 47%)')};
209
+ border-color: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 47%)')};
210
+ color: hsl(0 0% 9%);
168
211
  }
169
212
 
170
- .level.error {
171
- color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
172
- background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
213
+ .controls {
214
+ display: flex;
215
+ gap: 6px;
216
+ margin-left: auto;
173
217
  }
174
218
 
175
- .level.success {
176
- color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
177
- background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
219
+ .control-button {
220
+ background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
221
+ border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
222
+ border-radius: 4px;
223
+ padding: 4px 10px;
224
+ color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
225
+ cursor: pointer;
226
+ font-size: 12px;
227
+ font-weight: 500;
228
+ transition: all 0.15s;
178
229
  }
179
230
 
180
- .source {
181
- color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
182
- margin-right: 8px;
183
- flex-shrink: 0;
231
+ .control-button:hover {
232
+ background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 20%)')};
233
+ border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 25%)')};
234
+ color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
184
235
  }
185
236
 
186
- .message {
187
- color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
237
+ .control-button.active {
238
+ background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
239
+ border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
240
+ color: white;
241
+ }
242
+
243
+ .terminal-container {
188
244
  flex: 1;
245
+ overflow: hidden;
246
+ padding: 8px;
247
+ background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
248
+ }
249
+
250
+ .terminal-container .xterm {
251
+ height: 100%;
189
252
  }
190
253
 
191
- .empty-state {
254
+ .loading-state {
192
255
  display: flex;
193
256
  align-items: center;
194
257
  justify-content: center;
195
258
  height: 100%;
196
259
  color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
197
260
  font-style: italic;
261
+ font-size: 13px;
262
+ }
263
+
264
+ .metrics-bar {
265
+ background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
266
+ border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
267
+ padding: 6px 12px;
268
+ display: flex;
269
+ gap: 16px;
270
+ font-size: 11px;
271
+ font-weight: 500;
272
+ flex-shrink: 0;
273
+ }
274
+
275
+ .metric {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 4px;
198
279
  }
199
280
 
200
- /* Custom scrollbar */
201
- .logContainer::-webkit-scrollbar {
281
+ .metric::before {
282
+ content: '';
202
283
  width: 8px;
284
+ height: 8px;
285
+ border-radius: 50%;
203
286
  }
204
287
 
205
- .logContainer::-webkit-scrollbar-track {
206
- background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')};
288
+ .metric.error::before {
289
+ background: hsl(0 84.2% 60.2%);
207
290
  }
208
291
 
209
- .logContainer::-webkit-scrollbar-thumb {
210
- background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')};
211
- border-radius: 4px;
292
+ .metric.warn::before {
293
+ background: hsl(25 95% 53%);
212
294
  }
213
295
 
214
- .logContainer::-webkit-scrollbar-thumb:hover {
215
- background: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 40%)')};
296
+ .metric.info::before {
297
+ background: hsl(222.2 47.4% 51.2%);
298
+ }
299
+
300
+ .metric.success::before {
301
+ background: hsl(142.1 76.2% 36.3%);
302
+ }
303
+
304
+ .metric.debug::before {
305
+ background: hsl(0 0% 63.9%);
306
+ }
307
+
308
+ .metric.rate {
309
+ margin-left: auto;
310
+ color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
311
+ }
312
+
313
+ .metric.rate::before {
314
+ display: none;
216
315
  }
217
316
  `,
218
317
  ];
219
318
 
319
+ constructor() {
320
+ super();
321
+ domtools.elementBasic.setup();
322
+ }
323
+
220
324
  public render(): TemplateResult {
221
325
  return html`
222
326
  <div class="mainbox">
223
327
  <div class="header">
224
328
  <div class="title">${this.label}</div>
329
+ <div class="search-box">
330
+ <input
331
+ type="text"
332
+ placeholder="Search logs..."
333
+ .value=${this.searchQuery}
334
+ @input=${(e: InputEvent) => this.handleSearchInput(e)}
335
+ @keydown=${(e: KeyboardEvent) => this.handleSearchKeydown(e)}
336
+ />
337
+ <div class="search-nav">
338
+ <button @click=${() => this.searchPrevious()} title="Previous match">↑</button>
339
+ <button @click=${() => this.searchNext()} title="Next match">↓</button>
340
+ </div>
341
+ <button
342
+ class="filter-toggle ${this.filterMode ? 'active' : ''}"
343
+ @click=${() => this.toggleFilterMode()}
344
+ title="${this.filterMode ? 'Switch to highlight mode' : 'Switch to filter mode'}"
345
+ >
346
+ ${this.filterMode ? 'Filter' : 'Highlight'}
347
+ </button>
348
+ </div>
225
349
  <div class="controls">
226
- <button
350
+ <button
227
351
  class="control-button ${this.autoScroll ? 'active' : ''}"
228
- @click=${() => { this.autoScroll = !this.autoScroll; }}
352
+ @click=${() => this.toggleAutoScroll()}
229
353
  >
230
354
  Auto Scroll
231
355
  </button>
232
- <button
233
- class="control-button"
234
- @click=${() => { this.clearLogs(); }}
235
- >
356
+ <button class="control-button" @click=${() => this.clearLogs()}>
236
357
  Clear
237
358
  </button>
238
359
  </div>
239
360
  </div>
240
- <div class="logContainer">
241
- ${this.logEntries.length === 0
242
- ? html`<div class="empty-state">No logs to display</div>`
243
- : this.logEntries.map(entry => this.renderLogEntry(entry))
244
- }
361
+
362
+ <div class="terminal-container">
363
+ ${!this.terminalReady
364
+ ? html`<div class="loading-state">Loading terminal...</div>`
365
+ : ''}
245
366
  </div>
367
+
368
+ ${this.showMetrics
369
+ ? html`
370
+ <div class="metrics-bar">
371
+ <span class="metric error">errors: ${this.metrics.error}</span>
372
+ <span class="metric warn">warns: ${this.metrics.warn}</span>
373
+ <span class="metric info">info: ${this.metrics.info}</span>
374
+ <span class="metric success">success: ${this.metrics.success}</span>
375
+ <span class="metric debug">debug: ${this.metrics.debug}</span>
376
+ <span class="metric rate">${this.metrics.rate.toFixed(1)} logs/sec</span>
377
+ </div>
378
+ `
379
+ : ''}
246
380
  </div>
247
381
  `;
248
382
  }
249
383
 
250
- private renderLogEntry(entry: ILogEntry): TemplateResult {
251
- const timestamp = new Date(entry.timestamp).toLocaleTimeString('en-US', {
384
+ public async firstUpdated() {
385
+ this.domtoolsInstance = await this.domtoolsPromise;
386
+ await this.initializeTerminal();
387
+
388
+ // Process any initial log entries
389
+ if (this.logEntries.length > 0) {
390
+ for (const entry of this.logEntries) {
391
+ this.writeLogEntry(entry);
392
+ }
393
+ }
394
+ }
395
+
396
+ private async initializeTerminal() {
397
+ const libLoader = DeesServiceLibLoader.getInstance();
398
+
399
+ const [xtermBundle, fitBundle, searchBundle] = await Promise.all([
400
+ libLoader.loadXterm(),
401
+ libLoader.loadXtermFitAddon(),
402
+ libLoader.loadXtermSearchAddon(),
403
+ ]);
404
+
405
+ // Inject xterm CSS into shadow root (needed because shadow DOM doesn't inherit from document.head)
406
+ await this.injectXtermStylesIntoShadow();
407
+
408
+ this.terminal = new xtermBundle.Terminal({
409
+ cursorBlink: false,
410
+ disableStdin: true,
411
+ fontSize: 12,
412
+ fontFamily: "'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace",
413
+ theme: this.getTerminalTheme(),
414
+ scrollback: this.maxEntries,
415
+ convertEol: true,
416
+ });
417
+
418
+ this.fitAddon = new fitBundle.FitAddon();
419
+ this.searchAddon = new searchBundle.SearchAddon();
420
+
421
+ this.terminal.loadAddon(this.fitAddon);
422
+ this.terminal.loadAddon(this.searchAddon);
423
+
424
+ const container = this.shadowRoot!.querySelector('.terminal-container') as HTMLElement;
425
+ this.terminal.open(container);
426
+
427
+ // Fit after a small delay to ensure proper sizing
428
+ await new Promise((resolve) => requestAnimationFrame(resolve));
429
+ this.fitAddon.fit();
430
+
431
+ // Set up resize observer
432
+ this.resizeObserver = new ResizeObserver(() => {
433
+ this.fitAddon?.fit();
434
+ });
435
+ this.resizeObserver.observe(container);
436
+
437
+ // Subscribe to theme changes
438
+ this.terminalThemeSubscription = this.domtoolsInstance.themeManager.themeObservable.subscribe(() => {
439
+ if (this.terminal) {
440
+ this.terminal.options.theme = this.getTerminalTheme();
441
+ }
442
+ });
443
+
444
+ // Start rate calculation interval
445
+ this.rateInterval = setInterval(() => this.calculateRate(), 1000);
446
+
447
+ this.terminalReady = true;
448
+ }
449
+
450
+ private getTerminalTheme() {
451
+ const isDark = this.domtoolsInstance?.themeManager?.isDarkMode ?? true;
452
+ return isDark
453
+ ? {
454
+ background: '#0a0a0a',
455
+ foreground: '#e0e0e0',
456
+ cursor: '#e0e0e0',
457
+ selectionBackground: '#404040',
458
+ black: '#000000',
459
+ red: '#ff5555',
460
+ green: '#50fa7b',
461
+ yellow: '#f1fa8c',
462
+ blue: '#6272a4',
463
+ magenta: '#ff79c6',
464
+ cyan: '#8be9fd',
465
+ white: '#f8f8f2',
466
+ brightBlack: '#6272a4',
467
+ brightRed: '#ff6e6e',
468
+ brightGreen: '#69ff94',
469
+ brightYellow: '#ffffa5',
470
+ brightBlue: '#d6acff',
471
+ brightMagenta: '#ff92df',
472
+ brightCyan: '#a4ffff',
473
+ brightWhite: '#ffffff',
474
+ }
475
+ : {
476
+ background: '#ffffff',
477
+ foreground: '#333333',
478
+ cursor: '#333333',
479
+ selectionBackground: '#add6ff',
480
+ black: '#000000',
481
+ red: '#cd3131',
482
+ green: '#00bc00',
483
+ yellow: '#949800',
484
+ blue: '#0451a5',
485
+ magenta: '#bc05bc',
486
+ cyan: '#0598bc',
487
+ white: '#555555',
488
+ brightBlack: '#666666',
489
+ brightRed: '#cd3131',
490
+ brightGreen: '#14ce14',
491
+ brightYellow: '#b5ba00',
492
+ brightBlue: '#0451a5',
493
+ brightMagenta: '#bc05bc',
494
+ brightCyan: '#0598bc',
495
+ brightWhite: '#a5a5a5',
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Inject xterm CSS styles into shadow root
501
+ * This is needed because shadow DOM doesn't inherit styles from document.head
502
+ */
503
+ private async injectXtermStylesIntoShadow(): Promise<void> {
504
+ const styleId = 'xterm-shadow-styles';
505
+ if (this.shadowRoot!.getElementById(styleId)) {
506
+ return; // Already injected
507
+ }
508
+
509
+ const cssUrl = `${CDN_BASE}/xterm@${CDN_VERSIONS.xterm}/css/xterm.css`;
510
+ const response = await fetch(cssUrl);
511
+ const cssText = await response.text();
512
+
513
+ const style = document.createElement('style');
514
+ style.id = styleId;
515
+ style.textContent = cssText;
516
+ this.shadowRoot!.appendChild(style);
517
+ }
518
+
519
+ // =====================
520
+ // Structured Log Methods
521
+ // =====================
522
+
523
+ /**
524
+ * Add a single structured log entry
525
+ */
526
+ public addLog(level: ILogEntry['level'], message: string, source?: string) {
527
+ const entry: ILogEntry = {
528
+ timestamp: new Date().toISOString(),
529
+ level,
530
+ message,
531
+ source,
532
+ };
533
+
534
+ // Add to buffer
535
+ this.logBuffer.push(entry);
536
+ if (this.logBuffer.length > this.maxEntries) {
537
+ this.logBuffer.shift();
538
+ }
539
+
540
+ // Handle display based on filter mode
541
+ if (!this.filterMode || !this.searchQuery) {
542
+ // No filtering - show all entries
543
+ this.writeLogEntry(entry);
544
+ } else if (this.entryMatchesFilter(entry)) {
545
+ // Entry matches filter - reset trailing count and write entry
546
+ this.trailingHiddenCount = 0;
547
+ this.writeLogEntry(entry);
548
+ } else {
549
+ // Entry doesn't match - update trailing placeholder
550
+ this.updateTrailingPlaceholder();
551
+ }
552
+
553
+ this.updateMetrics(entry.level);
554
+ }
555
+
556
+ /**
557
+ * Add multiple structured log entries
558
+ */
559
+ public updateLog(entries?: ILogEntry[]) {
560
+ if (!entries) return;
561
+ for (const entry of entries) {
562
+ // Add to buffer
563
+ this.logBuffer.push(entry);
564
+ if (this.logBuffer.length > this.maxEntries) {
565
+ this.logBuffer.shift();
566
+ }
567
+
568
+ // Handle display based on filter mode
569
+ if (!this.filterMode || !this.searchQuery) {
570
+ // No filtering - show all entries
571
+ this.writeLogEntry(entry);
572
+ } else if (this.entryMatchesFilter(entry)) {
573
+ // Entry matches filter - reset trailing count and write entry
574
+ this.trailingHiddenCount = 0;
575
+ this.writeLogEntry(entry);
576
+ } else {
577
+ // Entry doesn't match - update trailing placeholder
578
+ this.updateTrailingPlaceholder();
579
+ }
580
+
581
+ this.updateMetrics(entry.level);
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Update the trailing hidden placeholder in real-time
587
+ * Clears the last line if a placeholder already exists, then writes updated count
588
+ */
589
+ private updateTrailingPlaceholder() {
590
+ if (!this.terminal) return;
591
+
592
+ if (this.trailingHiddenCount > 0) {
593
+ // Clear the previous placeholder line (move up, clear line, move to start)
594
+ this.terminal.write('\x1b[1A\x1b[2K\r');
595
+ }
596
+
597
+ this.trailingHiddenCount++;
598
+ this.writeHiddenPlaceholder(this.trailingHiddenCount);
599
+
600
+ if (this.autoScroll) {
601
+ this.terminal.scrollToBottom();
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Check if a log entry matches the current filter
607
+ */
608
+ private entryMatchesFilter(entry: ILogEntry): boolean {
609
+ if (!this.searchQuery) return true;
610
+ const query = this.searchQuery.toLowerCase();
611
+ return (
612
+ entry.message.toLowerCase().includes(query) ||
613
+ entry.level.toLowerCase().includes(query) ||
614
+ (entry.source?.toLowerCase().includes(query) ?? false)
615
+ );
616
+ }
617
+
618
+ private writeLogEntry(entry: ILogEntry) {
619
+ if (!this.terminal) return;
620
+
621
+ const formatted = this.formatLogEntry(entry);
622
+ this.terminal.writeln(formatted);
623
+
624
+ if (this.autoScroll) {
625
+ this.terminal.scrollToBottom();
626
+ }
627
+ }
628
+
629
+ private formatLogEntry(entry: ILogEntry): string {
630
+ const timestamp = this.formatTimestamp(entry.timestamp);
631
+ const levelColors: Record<ILogEntry['level'], string> = {
632
+ debug: '\x1b[90m', // Gray
633
+ info: '\x1b[36m', // Cyan
634
+ warn: '\x1b[33m', // Yellow
635
+ error: '\x1b[31m', // Red
636
+ success: '\x1b[32m', // Green
637
+ };
638
+ const reset = '\x1b[0m';
639
+ const dim = '\x1b[2m';
640
+
641
+ const levelStr = `${levelColors[entry.level]}[${entry.level.toUpperCase().padEnd(7)}]${reset}`;
642
+ const sourceStr = entry.source ? `${dim}[${entry.source}]${reset} ` : '';
643
+ const messageStr = this.applyHighlights(entry.message);
644
+
645
+ return `${dim}${timestamp}${reset} ${levelStr} ${sourceStr}${messageStr}`;
646
+ }
647
+
648
+ private formatTimestamp(isoString: string): string {
649
+ const date = new Date(isoString);
650
+ return date.toLocaleTimeString('en-US', {
252
651
  hour12: false,
253
652
  hour: '2-digit',
254
653
  minute: '2-digit',
255
654
  second: '2-digit',
256
- fractionalSecondDigits: 3
257
- });
655
+ fractionalSecondDigits: 3,
656
+ } as Intl.DateTimeFormatOptions);
657
+ }
258
658
 
259
- return html`
260
- <div class="logEntry">
261
- <span class="timestamp">${timestamp}</span>
262
- <span class="level ${entry.level}">${entry.level}</span>
263
- ${entry.source ? html`<span class="source">[${entry.source}]</span>` : ''}
264
- <span class="message">${entry.message}</span>
265
- </div>
266
- `;
659
+ private applyHighlights(text: string): string {
660
+ // Collect all keywords to highlight
661
+ const keywords = [...this.highlightKeywords];
662
+
663
+ // In filter mode, also highlight the search query
664
+ if (this.filterMode && this.searchQuery) {
665
+ keywords.push(this.searchQuery);
666
+ }
667
+
668
+ if (keywords.length === 0) return text;
669
+
670
+ let result = text;
671
+ for (const keyword of keywords) {
672
+ // Escape regex special characters
673
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
674
+ const regex = new RegExp(`(${escaped})`, 'gi');
675
+ // Yellow background, black text for highlights
676
+ result = result.replace(regex, '\x1b[43m\x1b[30m$1\x1b[0m');
677
+ }
678
+ return result;
267
679
  }
268
680
 
269
- public async firstUpdated() {
270
- await this.domtoolsPromise;
271
- this.logContainer = this.shadowRoot.querySelector('.logContainer');
272
-
273
- // Initialize with demo server logs
274
- const demoLogs: ILogEntry[] = [
275
- { timestamp: new Date().toISOString(), level: 'info', message: 'Server started on port 3000', source: 'Server' },
276
- { timestamp: new Date().toISOString(), level: 'debug', message: 'Loading configuration from /etc/app/config.json', source: 'Config' },
277
- { timestamp: new Date().toISOString(), level: 'info', message: 'Connected to MongoDB at mongodb://localhost:27017', source: 'Database' },
278
- { timestamp: new Date().toISOString(), level: 'success', message: 'Database connection established successfully', source: 'Database' },
279
- { timestamp: new Date().toISOString(), level: 'warn', message: 'No SSL certificate found, using self-signed certificate', source: 'Security' },
280
- { timestamp: new Date().toISOString(), level: 'info', message: 'API routes initialized: GET /api/users, POST /api/users, DELETE /api/users/:id', source: 'Router' },
281
- { timestamp: new Date().toISOString(), level: 'debug', message: 'Middleware stack: cors, bodyParser, authentication, errorHandler', source: 'Middleware' },
282
- { timestamp: new Date().toISOString(), level: 'info', message: 'WebSocket server listening on ws://localhost:3001', source: 'WebSocket' },
283
- ];
284
-
285
- this.logEntries = demoLogs;
286
- this.scrollToBottom();
287
- }
288
-
289
- public async updateLog(entries?: ILogEntry[]) {
290
- if (entries) {
291
- // Add new entries
292
- this.logEntries = [...this.logEntries, ...entries];
293
-
294
- // Trim if exceeds max entries
295
- if (this.logEntries.length > this.maxEntries) {
296
- this.logEntries = this.logEntries.slice(-this.maxEntries);
297
- }
298
-
299
- // Trigger re-render
300
- this.requestUpdate();
301
-
302
- // Auto-scroll if enabled
303
- await this.updateComplete;
304
- if (this.autoScroll) {
305
- this.scrollToBottom();
681
+ // =====================
682
+ // Raw Log Methods
683
+ // =====================
684
+
685
+ /**
686
+ * Write raw data to the terminal (for Docker logs, etc.)
687
+ */
688
+ public writeRaw(data: string) {
689
+ if (!this.terminal) return;
690
+ this.terminal.write(data);
691
+ this.recordLogEvent();
692
+
693
+ if (this.autoScroll) {
694
+ this.terminal.scrollToBottom();
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Write a raw line to the terminal
700
+ */
701
+ public writelnRaw(line: string) {
702
+ if (!this.terminal) return;
703
+ this.terminal.writeln(line);
704
+ this.recordLogEvent();
705
+
706
+ if (this.autoScroll) {
707
+ this.terminal.scrollToBottom();
708
+ }
709
+ }
710
+
711
+ // =====================
712
+ // Search Methods
713
+ // =====================
714
+
715
+ private handleSearchInput(e: InputEvent) {
716
+ const input = e.target as HTMLInputElement;
717
+ const newQuery = input.value;
718
+ const queryChanged = this.searchQuery !== newQuery;
719
+ this.searchQuery = newQuery;
720
+
721
+ if (this.filterMode && queryChanged) {
722
+ // Re-render with filtered logs
723
+ this.reRenderFilteredLogs();
724
+ } else if (this.searchQuery) {
725
+ // Just highlight/search in current view
726
+ this.searchAddon?.findNext(this.searchQuery);
727
+ }
728
+ }
729
+
730
+ private handleSearchKeydown(e: KeyboardEvent) {
731
+ if (e.key === 'Enter') {
732
+ if (e.shiftKey) {
733
+ this.searchPrevious();
734
+ } else {
735
+ this.searchNext();
306
736
  }
737
+ } else if (e.key === 'Escape') {
738
+ this.searchQuery = '';
739
+ (e.target as HTMLInputElement).value = '';
307
740
  }
308
741
  }
309
742
 
310
- public clearLogs() {
311
- this.logEntries = [];
312
- this.requestUpdate();
743
+ /**
744
+ * Search for a query in the terminal
745
+ */
746
+ public search(query: string): void {
747
+ this.searchQuery = query;
748
+ this.searchAddon?.findNext(query);
313
749
  }
314
750
 
315
- private scrollToBottom() {
316
- if (this.logContainer) {
317
- this.logContainer.scrollTop = this.logContainer.scrollHeight;
751
+ /**
752
+ * Find next search match
753
+ */
754
+ public searchNext(): void {
755
+ if (this.searchQuery) {
756
+ this.searchAddon?.findNext(this.searchQuery);
318
757
  }
319
758
  }
320
759
 
321
- public addLog(level: ILogEntry['level'], message: string, source?: string) {
322
- const newEntry: ILogEntry = {
323
- timestamp: new Date().toISOString(),
324
- level,
325
- message,
326
- source
760
+ /**
761
+ * Find previous search match
762
+ */
763
+ public searchPrevious(): void {
764
+ if (this.searchQuery) {
765
+ this.searchAddon?.findPrevious(this.searchQuery);
766
+ }
767
+ }
768
+
769
+ // =====================
770
+ // Control Methods
771
+ // =====================
772
+
773
+ private toggleAutoScroll() {
774
+ this.autoScroll = !this.autoScroll;
775
+ if (this.autoScroll && this.terminal) {
776
+ this.terminal.scrollToBottom();
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Toggle between filter mode and highlight mode
782
+ */
783
+ private toggleFilterMode() {
784
+ this.filterMode = !this.filterMode;
785
+ this.reRenderFilteredLogs();
786
+ }
787
+
788
+ /**
789
+ * Re-render logs based on current filter state
790
+ * In filter mode: show matching logs with placeholders for hidden entries
791
+ * In highlight mode: show all logs
792
+ */
793
+ private reRenderFilteredLogs() {
794
+ if (!this.terminal) return;
795
+
796
+ // Clear terminal and re-render
797
+ this.terminal.clear();
798
+
799
+ // Reset trailing count for fresh render
800
+ this.trailingHiddenCount = 0;
801
+
802
+ if (!this.filterMode || !this.searchQuery) {
803
+ // No filtering - show all entries
804
+ for (const entry of this.logBuffer) {
805
+ const formatted = this.formatLogEntry(entry);
806
+ this.terminal.writeln(formatted);
807
+ }
808
+ } else {
809
+ // Filter mode with placeholders for hidden entries
810
+ let hiddenCount = 0;
811
+
812
+ for (const entry of this.logBuffer) {
813
+ if (this.entryMatchesFilter(entry)) {
814
+ // Output placeholder for hidden entries if any
815
+ if (hiddenCount > 0) {
816
+ this.writeHiddenPlaceholder(hiddenCount);
817
+ hiddenCount = 0;
818
+ }
819
+ // Output the matching entry
820
+ const formatted = this.formatLogEntry(entry);
821
+ this.terminal.writeln(formatted);
822
+ } else {
823
+ hiddenCount++;
824
+ }
825
+ }
826
+
827
+ // Handle trailing hidden entries
828
+ if (hiddenCount > 0) {
829
+ this.writeHiddenPlaceholder(hiddenCount);
830
+ // Store trailing count for live updates
831
+ this.trailingHiddenCount = hiddenCount;
832
+ }
833
+ }
834
+
835
+ if (this.autoScroll) {
836
+ this.terminal.scrollToBottom();
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Write a placeholder line showing how many log entries are hidden by filter
842
+ */
843
+ private writeHiddenPlaceholder(count: number) {
844
+ const dim = '\x1b[2m';
845
+ const reset = '\x1b[0m';
846
+ const text = count === 1
847
+ ? `[1 log line hidden by filter ...]`
848
+ : `[${count} log lines hidden by filter ...]`;
849
+ this.terminal?.writeln(`${dim}${text}${reset}`);
850
+ }
851
+
852
+ /**
853
+ * Clear all logs and reset metrics
854
+ */
855
+ public clearLogs() {
856
+ this.terminal?.clear();
857
+ this.logBuffer = [];
858
+ this.trailingHiddenCount = 0;
859
+ this.resetMetrics();
860
+ }
861
+
862
+ /**
863
+ * Scroll to the bottom of the log
864
+ */
865
+ public scrollToBottom() {
866
+ this.terminal?.scrollToBottom();
867
+ }
868
+
869
+ // =====================
870
+ // Metrics Methods
871
+ // =====================
872
+
873
+ private updateMetrics(level: ILogEntry['level']) {
874
+ this.metrics = {
875
+ ...this.metrics,
876
+ [level]: this.metrics[level] + 1,
877
+ total: this.metrics.total + 1,
327
878
  };
328
- this.updateLog([newEntry]);
879
+ this.recordLogEvent();
880
+ }
881
+
882
+ private recordLogEvent() {
883
+ this.rateBuffer.push(Date.now());
884
+ }
885
+
886
+ private calculateRate() {
887
+ const now = Date.now();
888
+ // Keep only events from the last 10 seconds
889
+ this.rateBuffer = this.rateBuffer.filter((t) => now - t < 10000);
890
+ const rate = this.rateBuffer.length / 10;
891
+
892
+ if (rate !== this.metrics.rate) {
893
+ this.metrics = { ...this.metrics, rate };
894
+ }
895
+ }
896
+
897
+ private resetMetrics() {
898
+ this.metrics = { debug: 0, info: 0, warn: 0, error: 0, success: 0, total: 0, rate: 0 };
899
+ this.rateBuffer = [];
900
+ }
901
+
902
+ // =====================
903
+ // Lifecycle
904
+ // =====================
905
+
906
+ async disconnectedCallback() {
907
+ await super.disconnectedCallback();
908
+
909
+ if (this.resizeObserver) {
910
+ this.resizeObserver.disconnect();
911
+ }
912
+
913
+ if (this.terminalThemeSubscription) {
914
+ this.terminalThemeSubscription.unsubscribe();
915
+ }
916
+
917
+ if (this.rateInterval) {
918
+ clearInterval(this.rateInterval);
919
+ }
920
+
921
+ if (this.terminal) {
922
+ this.terminal.dispose();
923
+ }
329
924
  }
330
925
  }