Files
smartdb/ts_debugui/smartdb-debugui.ts

1133 lines
34 KiB
TypeScript

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<string, any>[] = [];
@state()
accessor documentsTotal: number = 0;
@state()
accessor expandedOplogSeqs: Set<number> = 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<T>(path: string, params: Record<string, any> = {}): Promise<T> {
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<ISmartDbMetrics>('/metrics'),
this.apiFetch<IOpLogStats>('/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<string, any>[]; 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<string, any> | null,
next: Record<string, any> | 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`
<div class="debugui">
<div class="header">
<div class="header-left">
<div class="title">SmartDB Debug</div>
<div class="status-dot ${isOnline ? '' : 'offline'}"></div>
</div>
</div>
<div class="tabs">
${(['dashboard', 'collections', 'oplog', 'revert'] as TTab[]).map(
(tab) => html`
<button
class="tab ${this.activeTab === tab ? 'active' : ''}"
@click=${() => this.switchTab(tab)}
>
${tab === 'dashboard'
? 'Dashboard'
: tab === 'collections'
? 'Collections'
: tab === 'oplog'
? 'OpLog'
: 'Revert'}
</button>
`,
)}
</div>
${this.activeTab === 'dashboard' ? this.renderDashboard() : ''}
${this.activeTab === 'collections' ? this.renderCollections() : ''}
${this.activeTab === 'oplog' ? this.renderOplog() : ''}
${this.activeTab === 'revert' ? this.renderRevert() : ''}
</div>
`;
}
private renderDashboard() {
return html`
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Databases</div>
<div class="stat-value">${this.metrics?.databases ?? '-'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Collections</div>
<div class="stat-value">${this.metrics?.collections ?? '-'}</div>
</div>
<div class="stat-card">
<div class="stat-label">OpLog Entries</div>
<div class="stat-value">${this.oplogStats?.totalEntries ?? '-'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Current Seq</div>
<div class="stat-value">${this.oplogStats?.currentSeq ?? '-'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value">
${this.metrics ? this.formatUptime(this.metrics.uptimeSeconds) : '-'}
</div>
</div>
</div>
${this.oplogStats
? html`
<div class="card">
<div class="card-title">Operations Breakdown</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Inserts</div>
<div class="stat-value" style="color: #22c55e">
${this.oplogStats.entriesByOp.insert}
</div>
</div>
<div class="stat-card">
<div class="stat-label">Updates</div>
<div class="stat-value" style="color: #3b82f6">
${this.oplogStats.entriesByOp.update}
</div>
</div>
<div class="stat-card">
<div class="stat-label">Deletes</div>
<div class="stat-value" style="color: #ef4444">
${this.oplogStats.entriesByOp.delete}
</div>
</div>
</div>
</div>
`
: ''}
`;
}
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`
<div class="collections-layout">
<div class="coll-sidebar">
${this.collections.length === 0
? html`<div class="empty-state">
<div class="empty-state-text">No collections</div>
</div>`
: this.collections.map(
(c) => html`
<div
class="coll-item ${this.selectedCollection?.db === c.db &&
this.selectedCollection?.name === c.name
? 'selected'
: ''}"
@click=${() => this.selectCollection(c.db, c.name)}
>
<div class="coll-name">${c.db}.${c.name}</div>
<div class="coll-count">${c.count} documents</div>
</div>
`,
)}
</div>
<div class="doc-viewer">
${this.selectedCollection
? html`
<div class="card-title">
${this.selectedCollection.db}.${this.selectedCollection.name}
(${this.documentsTotal} total)
</div>
${this.documents.length === 0
? html`<div class="empty-state">
<div class="empty-state-text">No documents</div>
</div>`
: this.documents.map(
(doc) => html`
<div class="doc-item">${JSON.stringify(doc, null, 2)}</div>
`,
)}
`
: html`<div class="empty-state">
<div class="empty-state-text">Select a collection</div>
<div class="empty-state-sub">
Choose a collection from the sidebar to browse its documents
</div>
</div>`}
</div>
</div>
`;
}
private renderOplog() {
const filtered = this.getFilteredOplog();
return html`
<div class="oplog-filters">
<button
class="filter-chip ${!this.oplogFilter.op ? 'active' : ''}"
@click=${() => (this.oplogFilter = { ...this.oplogFilter, op: undefined })}
>
All
</button>
<button
class="filter-chip ${this.oplogFilter.op === 'insert' ? 'active' : ''}"
@click=${() => (this.oplogFilter = { ...this.oplogFilter, op: 'insert' })}
>
Inserts
</button>
<button
class="filter-chip ${this.oplogFilter.op === 'update' ? 'active' : ''}"
@click=${() => (this.oplogFilter = { ...this.oplogFilter, op: 'update' })}
>
Updates
</button>
<button
class="filter-chip ${this.oplogFilter.op === 'delete' ? 'active' : ''}"
@click=${() => (this.oplogFilter = { ...this.oplogFilter, op: 'delete' })}
>
Deletes
</button>
</div>
${filtered.length === 0
? html`<div class="empty-state">
<div class="empty-state-text">No oplog entries</div>
<div class="empty-state-sub">Write operations will appear here as they occur</div>
</div>`
: [...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`
<div class="oplog-entry">
<div class="oplog-header" @click=${() => this.toggleOplogEntry(entry.seq)}>
<span class="oplog-seq">#${entry.seq}</span>
<span class="op-badge ${entry.op}">${entry.op}</span>
<span class="oplog-ns">${entry.db}.${entry.collection}</span>
<span class="oplog-docid">${entry.documentId.substring(0, 12)}...</span>
<span class="oplog-time">${this.formatTime(entry.timestampMs)}</span>
<span class="oplog-expand ${isExpanded ? 'expanded' : ''}">&#9654;</span>
</div>
${isExpanded
? html`
<div class="oplog-diff">
${entry.op === 'insert'
? html`
<div class="diff-label">Inserted Document</div>
<div class="doc-json-block">
${JSON.stringify(entry.document, null, 2)}
</div>
`
: entry.op === 'delete'
? html`
<div class="diff-label">Deleted Document</div>
<div class="doc-json-block">
${JSON.stringify(entry.previousDocument, null, 2)}
</div>
`
: html`
<div class="diff-label">Changes</div>
${diffs.length > 0
? diffs.map((d) => this.renderDiffRow(d))
: html`<div style="font-size: 12px; color: #94a3b8; padding: 4px 0">
No field-level changes
</div>`}
<div class="diff-label">Before</div>
<div class="doc-json-block">
${JSON.stringify(entry.previousDocument, null, 2)}
</div>
<div class="diff-label">After</div>
<div class="doc-json-block">
${JSON.stringify(entry.document, null, 2)}
</div>
`}
</div>
`
: ''}
</div>
`;
}
private renderDiffRow(diff: IDiffEntry) {
return html`
<div class="diff-row">
<span class="diff-path">${diff.path}</span>
${diff.type === 'added'
? html`<span class="diff-added">+ ${this.formatValue(diff.newValue)}</span>`
: diff.type === 'removed'
? html`<span class="diff-removed">- ${this.formatValue(diff.oldValue)}</span>`
: html`
<span class="diff-changed-old">${this.formatValue(diff.oldValue)}</span>
<span style="color: #94a3b8">-></span>
<span class="diff-changed-new">${this.formatValue(diff.newValue)}</span>
`}
</div>
`;
}
private renderRevert() {
const currentSeq = this.oplogStats?.currentSeq ?? 0;
return html`
<div class="card">
<div class="card-title">Point-in-Time Revert</div>
<p style="font-size: 13px; color: ${cssManager.bdTheme('#64748b', '#94a3b8')}; margin-bottom: 16px">
Revert the database to a specific oplog sequence number. All operations after that
point will be undone in reverse order.
Current sequence: <strong>${currentSeq}</strong>
</p>
<div class="revert-controls">
<label style="font-size: 13px; font-weight: 500">Target seq:</label>
<input
class="revert-input"
type="number"
min="0"
max="${currentSeq}"
.value=${String(this.revertTargetSeq)}
@input=${(e: InputEvent) => {
this.revertTargetSeq = parseInt((e.target as HTMLInputElement).value) || 0;
this.revertPreview = null;
}}
/>
<button
class="btn btn-primary"
?disabled=${this.revertTargetSeq <= 0 || this.revertTargetSeq > currentSeq}
@click=${this.handlePreviewRevert}
>
Preview
</button>
</div>
${this.revertPreview
? html`
<div class="revert-preview">
<div class="revert-preview-title">
Revert Preview: ${this.revertPreview.reverted} operations to undo
</div>
${this.revertPreview.entries?.map(
(e: any) => html`
<div style="font-size: 12px; padding: 2px 0; font-family: monospace">
#${e.seq} ${e.op} ${e.db}.${e.collection} (${e.documentId})
</div>
`,
)}
<div style="margin-top: 12px">
<button
class="btn btn-danger"
?disabled=${this.revertInProgress}
@click=${this.handleExecuteRevert}
>
${this.revertInProgress ? 'Reverting...' : 'Execute Revert'}
</button>
</div>
</div>
`
: ''}
</div>
<div class="card">
<div class="card-title">Recent Operations (newest first)</div>
${this.oplogEntries.length === 0
? html`<div class="empty-state">
<div class="empty-state-text">No operations recorded yet</div>
</div>`
: [...this.oplogEntries]
.reverse()
.slice(0, 20)
.map(
(entry) => html`
<div
style="display: flex; gap: 8px; align-items: center; padding: 6px 0; font-size: 12px; border-bottom: 1px solid ${cssManager.bdTheme('#f1f5f9', '#27272a')}"
>
<span class="oplog-seq">#${entry.seq}</span>
<span class="op-badge ${entry.op}">${entry.op}</span>
<span style="flex: 1">${entry.db}.${entry.collection}</span>
<span style="font-family: monospace; color: ${cssManager.bdTheme('#94a3b8', '#64748b')}"
>${entry.documentId.substring(0, 12)}</span
>
</div>
`,
)}
</div>
`;
}
}