import { DeesElement, customElement, html, css, property, state, cssManager, type CSSResult, type TemplateResult } from './plugins.js'; import type { SmartdbServer, IOpLogEntry, IOpLogStats, ICollectionInfo, ISmartDbMetrics, } from '../ts/index.js'; type TTab = 'dashboard' | 'collections' | 'oplog' | 'revert'; interface IDiffEntry { path: string; type: 'added' | 'removed' | 'changed'; oldValue?: any; newValue?: any; } @customElement('smartdb-debugui') export class SmartdbDebugUi extends DeesElement { /** Direct server reference (Node-side usage). */ @property({ type: Object }) accessor server: SmartdbServer | null = null; /** Base URL for HTTP API (browser usage, e.g. "" for same origin). When set, uses fetch instead of direct server calls. */ @property({ type: String }) accessor apiBaseUrl: string | null = null; @property({ type: Number }) accessor refreshInterval: number = 2000; @state() accessor activeTab: TTab = 'dashboard'; @state() accessor metrics: ISmartDbMetrics | null = null; @state() accessor oplogStats: IOpLogStats | null = null; @state() accessor oplogEntries: IOpLogEntry[] = []; @state() accessor collections: ICollectionInfo[] = []; @state() accessor selectedCollection: { db: string; name: string } | null = null; @state() accessor documents: Record[] = []; @state() accessor documentsTotal: number = 0; @state() accessor expandedOplogSeqs: Set = new Set(); @state() accessor revertTargetSeq: number = 0; @state() accessor revertPreview: { reverted: number; entries?: any[] } | null = null; @state() accessor revertInProgress: boolean = false; @state() accessor oplogFilter: { op?: string; collection?: string } = {}; private refreshTimer: any; static styles: CSSResult[] = [ cssManager.defaultStyles, css` :host { display: block; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .debugui { padding: 24px; background: ${cssManager.bdTheme('#f8fafc', '#09090b')}; min-height: 100vh; color: ${cssManager.bdTheme('#0f172a', '#f1f5f9')}; } .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } .header-left { display: flex; align-items: center; gap: 12px; } .title { font-size: 24px; font-weight: 700; } .status-dot { width: 10px; height: 10px; border-radius: 50%; background: ${cssManager.bdTheme('#22c55e', '#22c55e')}; } .status-dot.offline { background: ${cssManager.bdTheme('#ef4444', '#ef4444')}; } /* Tabs */ .tabs { display: flex; gap: 2px; background: ${cssManager.bdTheme('#e2e8f0', '#1e1e1e')}; border-radius: 10px; padding: 3px; margin-bottom: 24px; } .tab { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; border: none; background: none; } .tab:hover { color: ${cssManager.bdTheme('#0f172a', '#e2e8f0')}; } .tab.active { background: ${cssManager.bdTheme('#ffffff', '#27272a')}; color: ${cssManager.bdTheme('#0f172a', '#f1f5f9')}; box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0,0,0,0.08)', 'rgba(0,0,0,0.3)')}; } /* Cards */ .card { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .card-title { font-size: 14px; font-weight: 600; color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; } /* Stats grid */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 12px; padding: 20px; } .stat-label { font-size: 12px; font-weight: 500; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } .stat-value { font-size: 28px; font-weight: 700; color: ${cssManager.bdTheme('#0f172a', '#f1f5f9')}; } /* Collections */ .collections-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 500px; } .coll-sidebar { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 12px; overflow: hidden; } .coll-item { padding: 12px 16px; cursor: pointer; border-bottom: 1px solid ${cssManager.bdTheme('#f1f5f9', '#27272a')}; transition: background 0.1s ease; font-size: 13px; } .coll-item:hover { background: ${cssManager.bdTheme('#f8fafc', '#1f1f23')}; } .coll-item.selected { background: ${cssManager.bdTheme('#eff6ff', '#1e3a5f')}; color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')}; } .coll-name { font-weight: 500; } .coll-count { font-size: 11px; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; margin-top: 2px; } .doc-viewer { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 12px; padding: 20px; overflow: auto; } .doc-item { background: ${cssManager.bdTheme('#f8fafc', '#0f0f12')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } /* OpLog */ .oplog-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } .filter-chip { padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; background: ${cssManager.bdTheme('#ffffff', '#18181b')}; color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; transition: all 0.15s ease; } .filter-chip:hover, .filter-chip.active { background: ${cssManager.bdTheme('#eff6ff', '#1e3a5f')}; border-color: ${cssManager.bdTheme('#93c5fd', '#3b82f6')}; color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')}; } .oplog-entry { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 10px; margin-bottom: 8px; overflow: hidden; transition: box-shadow 0.15s ease; } .oplog-entry:hover { box-shadow: 0 2px 8px ${cssManager.bdTheme('rgba(0,0,0,0.06)', 'rgba(0,0,0,0.2)')}; } .oplog-header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; } .oplog-seq { font-family: monospace; font-size: 11px; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; min-width: 40px; } .op-badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; } .op-badge.insert { background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; color: ${cssManager.bdTheme('#15803d', '#86efac')}; } .op-badge.update { background: ${cssManager.bdTheme('#dbeafe', '#1e3a8a')}; color: ${cssManager.bdTheme('#1e40af', '#93c5fd')}; } .op-badge.delete { background: ${cssManager.bdTheme('#fee2e2', '#7f1d1d')}; color: ${cssManager.bdTheme('#dc2626', '#fca5a5')}; } .oplog-ns { font-size: 13px; font-weight: 500; flex: 1; } .oplog-time { font-size: 11px; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; } .oplog-docid { font-size: 11px; font-family: monospace; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; } .oplog-expand { font-size: 11px; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; transition: transform 0.2s ease; } .oplog-expand.expanded { transform: rotate(90deg); } .oplog-diff { padding: 0 16px 16px; border-top: 1px solid ${cssManager.bdTheme('#f1f5f9', '#27272a')}; } .diff-row { display: flex; align-items: baseline; gap: 8px; padding: 4px 0; font-family: monospace; font-size: 12px; line-height: 1.6; } .diff-path { color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; min-width: 120px; } .diff-added { color: ${cssManager.bdTheme('#15803d', '#86efac')}; background: ${cssManager.bdTheme('#f0fdf4', '#052e16')}; padding: 1px 4px; border-radius: 3px; } .diff-removed { color: ${cssManager.bdTheme('#dc2626', '#fca5a5')}; background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; padding: 1px 4px; border-radius: 3px; } .diff-changed-old { color: ${cssManager.bdTheme('#dc2626', '#fca5a5')}; background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; padding: 1px 4px; border-radius: 3px; text-decoration: line-through; } .diff-changed-new { color: ${cssManager.bdTheme('#15803d', '#86efac')}; background: ${cssManager.bdTheme('#f0fdf4', '#052e16')}; padding: 1px 4px; border-radius: 3px; } /* Revert */ .revert-controls { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; } .revert-input { padding: 8px 12px; border-radius: 8px; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; background: ${cssManager.bdTheme('#ffffff', '#0f0f12')}; color: ${cssManager.bdTheme('#0f172a', '#f1f5f9')}; font-size: 14px; font-family: monospace; width: 120px; outline: none; } .revert-input:focus { border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; } .btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all 0.15s ease; } .btn-primary { background: ${cssManager.bdTheme('#3b82f6', '#2563eb')}; color: white; } .btn-primary:hover { background: ${cssManager.bdTheme('#2563eb', '#1d4ed8')}; } .btn-danger { background: ${cssManager.bdTheme('#ef4444', '#dc2626')}; color: white; } .btn-danger:hover { background: ${cssManager.bdTheme('#dc2626', '#b91c1c')}; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .revert-preview { background: ${cssManager.bdTheme('#fffbeb', '#1c1305')}; border: 1px solid ${cssManager.bdTheme('#fcd34d', '#854d0e')}; border-radius: 10px; padding: 16px; margin-bottom: 16px; } .revert-preview-title { font-weight: 600; margin-bottom: 8px; color: ${cssManager.bdTheme('#92400e', '#fbbf24')}; } .empty-state { text-align: center; padding: 48px 24px; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}; } .empty-state-text { font-size: 15px; margin-bottom: 4px; } .empty-state-sub { font-size: 13px; } .doc-json-block { background: ${cssManager.bdTheme('#f8fafc', '#0f0f12')}; border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#27272a')}; border-radius: 6px; padding: 12px; margin-top: 8px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; } .diff-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 12px; margin-bottom: 4px; color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; } `, ]; async connectedCallback() { await super.connectedCallback(); // Auto-detect: if no server and no explicit apiBaseUrl, default to same-origin HTTP. if (!this.server && this.apiBaseUrl === null) { this.apiBaseUrl = ''; } this.startRefreshing(); } async disconnectedCallback() { await super.disconnectedCallback(); this.stopRefreshing(); } private startRefreshing() { if (this.refreshTimer) clearInterval(this.refreshTimer); this.refresh(); this.refreshTimer = setInterval(() => this.refresh(), this.refreshInterval); } private stopRefreshing() { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } } // --- Data access layer (supports both direct server calls and HTTP fetch) --- private get useHttp(): boolean { return this.apiBaseUrl !== null; } private async apiFetch(path: string, params: Record = {}): Promise { const base = this.apiBaseUrl ?? ''; const url = new URL(`${base}/api/smartdb${path}`, window.location.origin); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); } const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } private async refresh() { if (!this.useHttp && !this.server?.running) { this.metrics = null; this.oplogStats = null; return; } try { if (this.useHttp) { const [metrics, oplogStats] = await Promise.all([ this.apiFetch('/metrics'), this.apiFetch('/oplog/stats'), ]); this.metrics = metrics; this.oplogStats = oplogStats; } else { const [metrics, oplogStats] = await Promise.all([ this.server!.getMetrics(), this.server!.getOpLogStats(), ]); this.metrics = metrics; this.oplogStats = oplogStats; } if (this.activeTab === 'collections' && this.collections.length === 0) { await this.loadCollections(); } if (this.activeTab === 'oplog' || this.activeTab === 'revert') { await this.loadOplog(); } } catch { // Server may not be running yet. } } private async loadOplog() { if (!this.useHttp && !this.server?.running) return; if (this.useHttp) { const result = await this.apiFetch<{ entries: IOpLogEntry[] }>('/oplog', { limit: 200 }); this.oplogEntries = result.entries; } else { const result = await this.server!.getOpLog({ limit: 200 }); this.oplogEntries = result.entries; } } private async loadCollections() { if (!this.useHttp && !this.server?.running) return; if (this.useHttp) { const result = await this.apiFetch<{ collections: ICollectionInfo[] }>('/collections'); this.collections = result.collections; } else { this.collections = await this.server!.getCollections(); } } private async selectCollection(db: string, name: string) { this.selectedCollection = { db, name }; if (this.useHttp) { const result = await this.apiFetch<{ documents: Record[]; total: number }>( '/documents', { db, collection: name, limit: 50, skip: 0 }, ); this.documents = result.documents; this.documentsTotal = result.total; } else { if (!this.server?.running) return; const result = await this.server.getDocuments(db, name, 50, 0); this.documents = result.documents; this.documentsTotal = result.total; } } private toggleOplogEntry(seq: number) { const next = new Set(this.expandedOplogSeqs); if (next.has(seq)) { next.delete(seq); } else { next.add(seq); } this.expandedOplogSeqs = next; } private async handlePreviewRevert() { if (this.revertTargetSeq <= 0) return; if (this.useHttp) { this.revertPreview = await this.apiFetch<{ reverted: number; entries?: any[] }>( '/revert', { seq: this.revertTargetSeq, dryRun: true }, ); } else { if (!this.server?.running) return; this.revertPreview = await this.server.revertToSeq(this.revertTargetSeq, true); } } private async handleExecuteRevert() { if (this.revertTargetSeq <= 0) return; this.revertInProgress = true; try { if (this.useHttp) { await this.apiFetch('/revert', { seq: this.revertTargetSeq, dryRun: false }); } else { if (!this.server?.running) return; await this.server.revertToSeq(this.revertTargetSeq, false); } this.revertPreview = null; this.revertTargetSeq = 0; await this.refresh(); } finally { this.revertInProgress = false; } } private async switchTab(tab: TTab) { this.activeTab = tab; if (tab === 'collections') { await this.loadCollections(); } if (tab === 'oplog' || tab === 'revert') { await this.loadOplog(); } } // --- Diff computation --- private computeDiff( prev: Record | null, next: Record | null, ): IDiffEntry[] { const diffs: IDiffEntry[] = []; this.diffRecursive(prev || {}, next || {}, '', diffs); return diffs; } private diffRecursive( a: any, b: any, path: string, diffs: IDiffEntry[], ) { const aKeys = new Set(a && typeof a === 'object' ? Object.keys(a) : []); const bKeys = new Set(b && typeof b === 'object' ? Object.keys(b) : []); const allKeys = new Set([...aKeys, ...bKeys]); for (const key of allKeys) { const fullPath = path ? `${path}.${key}` : key; const inA = aKeys.has(key); const inB = bKeys.has(key); if (!inA && inB) { diffs.push({ path: fullPath, type: 'added', newValue: b[key] }); } else if (inA && !inB) { diffs.push({ path: fullPath, type: 'removed', oldValue: a[key] }); } else if ( typeof a[key] === 'object' && a[key] !== null && typeof b[key] === 'object' && b[key] !== null && !Array.isArray(a[key]) && !Array.isArray(b[key]) ) { this.diffRecursive(a[key], b[key], fullPath, diffs); } else if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) { diffs.push({ path: fullPath, type: 'changed', oldValue: a[key], newValue: b[key], }); } } } private formatValue(val: any): string { if (val === null || val === undefined) return 'null'; if (typeof val === 'string') return `"${val}"`; if (typeof val === 'object') return JSON.stringify(val); return String(val); } private formatTime(timestampMs: number): string { return new Date(timestampMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, }); } // --- Render --- render(): TemplateResult { const isOnline = this.useHttp ? this.metrics !== null : (this.server?.running ?? false); return html`
SmartDB Debug
${(['dashboard', 'collections', 'oplog', 'revert'] as TTab[]).map( (tab) => html` `, )}
${this.activeTab === 'dashboard' ? this.renderDashboard() : ''} ${this.activeTab === 'collections' ? this.renderCollections() : ''} ${this.activeTab === 'oplog' ? this.renderOplog() : ''} ${this.activeTab === 'revert' ? this.renderRevert() : ''}
`; } private renderDashboard() { return html`
Databases
${this.metrics?.databases ?? '-'}
Collections
${this.metrics?.collections ?? '-'}
OpLog Entries
${this.oplogStats?.totalEntries ?? '-'}
Current Seq
${this.oplogStats?.currentSeq ?? '-'}
Uptime
${this.metrics ? this.formatUptime(this.metrics.uptimeSeconds) : '-'}
${this.oplogStats ? html`
Operations Breakdown
Inserts
${this.oplogStats.entriesByOp.insert}
Updates
${this.oplogStats.entriesByOp.update}
Deletes
${this.oplogStats.entriesByOp.delete}
` : ''} `; } private formatUptime(secs: number): string { if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m`; return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`; } private renderCollections() { return html`
${this.collections.length === 0 ? html`
No collections
` : this.collections.map( (c) => html`
this.selectCollection(c.db, c.name)} >
${c.db}.${c.name}
${c.count} documents
`, )}
${this.selectedCollection ? html`
${this.selectedCollection.db}.${this.selectedCollection.name} (${this.documentsTotal} total)
${this.documents.length === 0 ? html`
No documents
` : this.documents.map( (doc) => html`
${JSON.stringify(doc, null, 2)}
`, )} ` : html`
Select a collection
Choose a collection from the sidebar to browse its documents
`}
`; } private renderOplog() { const filtered = this.getFilteredOplog(); return html`
${filtered.length === 0 ? html`
No oplog entries
Write operations will appear here as they occur
` : [...filtered].reverse().map((entry) => this.renderOplogEntry(entry))} `; } private getFilteredOplog(): IOpLogEntry[] { let entries = this.oplogEntries; if (this.oplogFilter.op) { entries = entries.filter((e) => e.op === this.oplogFilter.op); } if (this.oplogFilter.collection) { entries = entries.filter( (e) => `${e.db}.${e.collection}` === this.oplogFilter.collection, ); } return entries; } private renderOplogEntry(entry: IOpLogEntry) { const isExpanded = this.expandedOplogSeqs.has(entry.seq); const diffs = isExpanded ? this.computeDiff(entry.previousDocument, entry.document) : []; return html`
this.toggleOplogEntry(entry.seq)}> #${entry.seq} ${entry.op} ${entry.db}.${entry.collection} ${entry.documentId.substring(0, 12)}... ${this.formatTime(entry.timestampMs)}
${isExpanded ? html`
${entry.op === 'insert' ? html`
Inserted Document
${JSON.stringify(entry.document, null, 2)}
` : entry.op === 'delete' ? html`
Deleted Document
${JSON.stringify(entry.previousDocument, null, 2)}
` : html`
Changes
${diffs.length > 0 ? diffs.map((d) => this.renderDiffRow(d)) : html`
No field-level changes
`}
Before
${JSON.stringify(entry.previousDocument, null, 2)}
After
${JSON.stringify(entry.document, null, 2)}
`}
` : ''}
`; } private renderDiffRow(diff: IDiffEntry) { return html`
${diff.path} ${diff.type === 'added' ? html`+ ${this.formatValue(diff.newValue)}` : diff.type === 'removed' ? html`- ${this.formatValue(diff.oldValue)}` : html` ${this.formatValue(diff.oldValue)} -> ${this.formatValue(diff.newValue)} `}
`; } private renderRevert() { const currentSeq = this.oplogStats?.currentSeq ?? 0; return html`
Point-in-Time Revert

Revert the database to a specific oplog sequence number. All operations after that point will be undone in reverse order. Current sequence: ${currentSeq}

{ this.revertTargetSeq = parseInt((e.target as HTMLInputElement).value) || 0; this.revertPreview = null; }} />
${this.revertPreview ? html`
Revert Preview: ${this.revertPreview.reverted} operations to undo
${this.revertPreview.entries?.map( (e: any) => html`
#${e.seq} ${e.op} ${e.db}.${e.collection} (${e.documentId})
`, )}
` : ''}
Recent Operations (newest first)
${this.oplogEntries.length === 0 ? html`
No operations recorded yet
` : [...this.oplogEntries] .reverse() .slice(0, 20) .map( (entry) => html`
#${entry.seq} ${entry.op} ${entry.db}.${entry.collection} ${entry.documentId.substring(0, 12)}
`, )}
`; } }