1133 lines
34 KiB
TypeScript
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' : ''}">▶</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>
|
|
`;
|
|
}
|
|
}
|