feat(web): add database overview panel, collection overview and resizable panels; show/hide system databases; use code editor with change-tracking in document view; add getDatabaseStats API and typings; enable overwrite for S3 uploads

This commit is contained in:
2026-01-25 17:34:52 +00:00
parent 2ca5f52da3
commit a26e7a5a20
17 changed files with 718 additions and 143 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tsview',
version: '1.3.0',
version: '1.4.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
}

View File

@@ -13,3 +13,4 @@ export * from './tsview-mongo-collections.js';
export * from './tsview-mongo-documents.js';
export * from './tsview-mongo-document.js';
export * from './tsview-mongo-indexes.js';
export * from './tsview-mongo-db-overview.js';

View File

@@ -45,6 +45,9 @@ export class TsviewApp extends DeesElement {
@state()
private accessor newDatabaseName: string = '';
@state()
private accessor showSystemDatabases: boolean = false;
@state()
private accessor showS3CreateDialog: boolean = false;
@@ -57,6 +60,12 @@ export class TsviewApp extends DeesElement {
@state()
private accessor s3CreateDialogName: string = '';
@state()
private accessor sidebarWidth: number = 240;
@state()
private accessor isResizingSidebar: boolean = false;
public static styles = [
cssManager.defaultStyles,
themeStyles,
@@ -130,10 +139,22 @@ export class TsviewApp extends DeesElement {
.app-main {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-columns: var(--sidebar-width, 240px) 4px 1fr;
overflow: hidden;
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.sidebar {
background: #1e1e1e;
border-right: 1px solid #333;
@@ -389,6 +410,15 @@ export class TsviewApp extends DeesElement {
`,
];
private readonly SYSTEM_DATABASES = ['admin', 'config', 'local'];
private get visibleDatabases() {
if (this.showSystemDatabases) {
return this.databases;
}
return this.databases.filter(db => !this.SYSTEM_DATABASES.includes(db.name));
}
async connectedCallback() {
super.connectedCallback();
await this.loadData();
@@ -423,8 +453,15 @@ export class TsviewApp extends DeesElement {
}
private selectDatabase(db: string) {
this.selectedDatabase = db;
this.selectedCollection = '';
if (this.selectedDatabase === db) {
// Collapse - clicking the same database again
// Keep the collection selection intact
this.selectedDatabase = '';
} else {
// Switch to different database - clear collection
this.selectedDatabase = db;
this.selectedCollection = '';
}
}
private selectCollection(collection: string) {
@@ -635,6 +672,38 @@ export class TsviewApp extends DeesElement {
]);
}
private handleSidebarContextMenu(event: MouseEvent) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: this.showSystemDatabases ? 'Hide System Databases' : 'Show System Databases',
iconName: this.showSystemDatabases ? 'lucide:eyeOff' : 'lucide:eye',
action: async () => {
this.showSystemDatabases = !this.showSystemDatabases;
},
},
]);
}
private startSidebarResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingSidebar = true;
document.addEventListener('mousemove', this.handleSidebarResize);
document.addEventListener('mouseup', this.endSidebarResize);
};
private handleSidebarResize = (e: MouseEvent) => {
if (!this.isResizingSidebar) return;
const newWidth = Math.min(Math.max(e.clientX, 150), 500);
this.sidebarWidth = newWidth;
};
private endSidebarResize = () => {
this.isResizingSidebar = false;
document.removeEventListener('mousemove', this.handleSidebarResize);
document.removeEventListener('mouseup', this.endSidebarResize);
};
render() {
return html`
<div class="app-container">
@@ -669,8 +738,12 @@ export class TsviewApp extends DeesElement {
</nav>
</header>
<main class="app-main">
<main class="app-main" style="--sidebar-width: ${this.sidebarWidth}px">
${this.renderSidebar()}
<div
class="resize-divider ${this.isResizingSidebar ? 'active' : ''}"
@mousedown=${this.startSidebarResize}
></div>
${this.renderContent()}
</main>
</div>
@@ -849,7 +922,7 @@ export class TsviewApp extends DeesElement {
if (this.viewMode === 'mongo') {
return html`
<aside class="sidebar">
<aside class="sidebar" @contextmenu=${(e: MouseEvent) => this.handleSidebarContextMenu(e)}>
<div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -859,9 +932,9 @@ export class TsviewApp extends DeesElement {
New Database
</button>
<div class="sidebar-list">
${this.databases.length === 0
${this.visibleDatabases.length === 0
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
: this.databases.map((db) => this.renderDatabaseGroup(db))}
: this.visibleDatabases.map((db) => this.renderDatabaseGroup(db))}
</div>
</aside>
`;
@@ -954,6 +1027,17 @@ export class TsviewApp extends DeesElement {
`;
}
// Show database overview when __overview__ is selected
if (this.selectedCollection === '__overview__') {
return html`
<div class="content-area">
<tsview-mongo-db-overview
.databaseName=${this.selectedDatabase}
></tsview-mongo-db-overview>
</div>
`;
}
return html`
<div class="content-area">
<tsview-mongo-browser

View File

@@ -24,6 +24,12 @@ export class TsviewMongoBrowser extends DeesElement {
@state()
private accessor stats: ICollectionStats | null = null;
@state()
private accessor editorWidth: number = 400;
@state()
private accessor isResizingEditor: boolean = false;
public static styles = [
cssManager.defaultStyles,
themeStyles,
@@ -102,11 +108,23 @@ export class TsviewMongoBrowser extends DeesElement {
.content {
flex: 1;
display: grid;
grid-template-columns: 1fr 400px;
gap: 16px;
grid-template-columns: 1fr 4px var(--editor-width, 400px);
gap: 0;
overflow: hidden;
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.main-panel {
overflow: auto;
background: rgba(0, 0, 0, 0.2);
@@ -117,6 +135,7 @@ export class TsviewMongoBrowser extends DeesElement {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
margin-left: 12px;
}
@media (max-width: 1200px) {
@@ -124,7 +143,8 @@ export class TsviewMongoBrowser extends DeesElement {
grid-template-columns: 1fr;
}
.detail-panel {
.detail-panel,
.resize-divider {
display: none;
}
}
@@ -162,6 +182,28 @@ export class TsviewMongoBrowser extends DeesElement {
this.selectedDocumentId = e.detail.documentId;
}
private startEditorResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingEditor = true;
document.addEventListener('mousemove', this.handleEditorResize);
document.addEventListener('mouseup', this.endEditorResize);
};
private handleEditorResize = (e: MouseEvent) => {
if (!this.isResizingEditor) return;
const contentEl = this.shadowRoot?.querySelector('.content');
if (!contentEl) return;
const containerRect = contentEl.getBoundingClientRect();
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 300), 700);
this.editorWidth = newWidth;
};
private endEditorResize = () => {
this.isResizingEditor = false;
document.removeEventListener('mousemove', this.handleEditorResize);
document.removeEventListener('mouseup', this.endEditorResize);
};
render() {
return html`
<div class="browser-container">
@@ -201,7 +243,7 @@ export class TsviewMongoBrowser extends DeesElement {
</div>
</div>
<div class="content">
<div class="content" style="--editor-width: ${this.editorWidth}px">
<div class="main-panel">
${this.activeTab === 'documents'
? html`
@@ -225,6 +267,11 @@ export class TsviewMongoBrowser extends DeesElement {
`}
</div>
<div
class="resize-divider ${this.isResizingEditor ? 'active' : ''}"
@mousedown=${this.startEditorResize}
></div>
<div class="detail-panel">
<tsview-mongo-document
.databaseName=${this.databaseName}

View File

@@ -90,6 +90,33 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px;
font-style: italic;
}
.overview-item {
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.1s;
color: #a5d6a7;
margin-bottom: 4px;
}
.overview-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.overview-item.selected {
background: rgba(165, 214, 167, 0.15);
color: #a5d6a7;
}
.overview-item svg {
width: 14px;
height: 14px;
}
`,
];
@@ -168,36 +195,56 @@ export class TsviewMongoCollections extends DeesElement {
await this.loadCollections();
}
private selectOverview() {
this.dispatchEvent(
new CustomEvent('collection-selected', {
detail: '__overview__',
bubbles: true,
composed: true,
})
);
}
render() {
if (this.loading) {
return html`<div class="loading-state">Loading collections...</div>`;
}
if (this.collections.length === 0) {
return html`<div class="empty-state">No collections</div>`;
}
return html`
<div class="collections-list">
${this.collections.map(
(coll) => html`
<div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
@click=${() => this.selectCollection(coll.name)}
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
>
<span class="collection-name">
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
${coll.name}
</span>
${coll.count !== undefined
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''}
</div>
`
)}
<div
class="overview-item ${this.selectedCollection === '__overview__' ? 'selected' : ''}"
@click=${() => this.selectOverview()}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Overview
</div>
${this.collections.length === 0
? html`<div class="empty-state">No collections</div>`
: this.collections.map(
(coll) => html`
<div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
@click=${() => this.selectCollection(coll.name)}
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
>
<span class="collection-name">
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
${coll.name}
</span>
${coll.count !== undefined
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''}
</div>
`
)}
</div>
`;
}

View File

@@ -0,0 +1,291 @@
import * as plugins from '../plugins.js';
import { apiService, type IDatabaseStats } from '../services/index.js';
import { formatSize, formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@customElement('tsview-mongo-db-overview')
export class TsviewMongoDbOverview extends DeesElement {
@property({ type: String })
public accessor databaseName: string = '';
@state()
private accessor stats: IDatabaseStats | null = null;
@state()
private accessor loading: boolean = false;
@state()
private accessor error: string = '';
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
}
.overview-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
box-sizing: border-box;
overflow: auto;
}
.header {
margin-bottom: 24px;
}
.header-title {
font-size: 24px;
font-weight: 600;
color: #fff;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 12px;
}
.header-title svg {
width: 28px;
height: 28px;
color: #888;
}
.header-subtitle {
color: #888;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #e0e0e0;
}
.stat-value.small {
font-size: 18px;
}
.stat-description {
font-size: 11px;
color: #666;
}
.section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #333;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
font-size: 14px;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #f87171;
text-align: center;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
text-align: center;
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
`,
];
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('databaseName') && this.databaseName) {
this.loadStats();
}
}
async connectedCallback() {
super.connectedCallback();
if (this.databaseName) {
await this.loadStats();
}
}
private async loadStats() {
if (!this.databaseName) return;
this.loading = true;
this.error = '';
try {
this.stats = await apiService.getDatabaseStats(this.databaseName);
} catch (err) {
console.error('Error loading database stats:', err);
this.error = 'Failed to load database statistics';
}
this.loading = false;
}
render() {
if (!this.databaseName) {
return html`
<div class="overview-container">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<p>Select a database to view overview</p>
</div>
</div>
`;
}
if (this.loading) {
return html`
<div class="overview-container">
<div class="loading-state">Loading database statistics...</div>
</div>
`;
}
if (this.error) {
return html`
<div class="overview-container">
<div class="error-state">${this.error}</div>
</div>
`;
}
if (!this.stats) {
return html`
<div class="overview-container">
<div class="empty-state">
<p>No statistics available</p>
</div>
</div>
`;
}
return html`
<div class="overview-container">
<div class="header">
<h1 class="header-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
${this.databaseName}
</h1>
<p class="header-subtitle">Database Overview</p>
</div>
<div class="section">
<div class="section-title">Storage</div>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Collections</span>
<span class="stat-value">${this.stats.collections}</span>
<span class="stat-description">Total collections in database</span>
</div>
<div class="stat-card">
<span class="stat-label">Documents</span>
<span class="stat-value">${formatCount(this.stats.objects) || this.stats.objects}</span>
<span class="stat-description">Total documents stored</span>
</div>
<div class="stat-card">
<span class="stat-label">Avg Document Size</span>
<span class="stat-value small">${formatSize(this.stats.avgObjSize)}</span>
<span class="stat-description">Average size per document</span>
</div>
<div class="stat-card">
<span class="stat-label">Data Size</span>
<span class="stat-value small">${formatSize(this.stats.dataSize)}</span>
<span class="stat-description">Uncompressed data size</span>
</div>
<div class="stat-card">
<span class="stat-label">Storage Size</span>
<span class="stat-value small">${formatSize(this.stats.storageSize)}</span>
<span class="stat-description">Allocated storage</span>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Indexes</div>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Index Count</span>
<span class="stat-value">${this.stats.indexes}</span>
<span class="stat-description">Total indexes in database</span>
</div>
<div class="stat-card">
<span class="stat-label">Index Size</span>
<span class="stat-value small">${formatSize(this.stats.indexSize)}</span>
<span class="stat-description">Total index storage</span>
</div>
</div>
</div>
</div>
`;
}
}

View File

@@ -25,7 +25,10 @@ export class TsviewMongoDocument extends DeesElement {
private accessor editing: boolean = false;
@state()
private accessor editContent: string = '';
private accessor originalContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state()
private accessor error: string = '';
@@ -101,56 +104,13 @@ export class TsviewMongoDocument extends DeesElement {
.content {
flex: 1;
overflow: auto;
padding: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.json-view {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: #ccc;
}
.json-key {
color: #e0e0e0;
}
.json-string {
color: #a5d6a7;
}
.json-number {
color: #fbbf24;
}
.json-boolean {
color: #f87171;
}
.json-null {
color: #888;
}
.edit-area {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.6;
padding: 12px;
resize: none;
}
.edit-area:focus {
outline: none;
border-color: #404040;
.content dees-input-code {
flex: 1;
}
.empty-state {
@@ -190,10 +150,12 @@ export class TsviewMongoDocument extends DeesElement {
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('documentId')) {
this.editing = false;
this.hasChanges = false;
if (this.documentId) {
this.loadDocument();
} else {
this.document = null;
this.originalContent = '';
}
}
}
@@ -210,6 +172,8 @@ export class TsviewMongoDocument extends DeesElement {
this.collectionName,
this.documentId
);
this.originalContent = JSON.stringify(this.document, null, 2);
this.hasChanges = false;
} catch (err) {
console.error('Error loading document:', err);
this.error = 'Failed to load document';
@@ -219,18 +183,37 @@ export class TsviewMongoDocument extends DeesElement {
}
private startEditing() {
this.editContent = JSON.stringify(this.document, null, 2);
this.editing = true;
}
private cancelEditing() {
this.editing = false;
this.editContent = '';
// Reset content to original
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
if (codeEditor) {
codeEditor.value = this.originalContent;
}
this.hasChanges = false;
}
private handleContentChange(e: CustomEvent) {
const newValue = e.detail as string;
this.hasChanges = newValue !== this.originalContent;
}
private handleDiscard() {
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
if (codeEditor) {
codeEditor.value = this.originalContent;
}
this.hasChanges = false;
}
private async saveDocument() {
try {
const updatedDoc = JSON.parse(this.editContent);
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
const content = codeEditor?.value || this.originalContent;
const updatedDoc = JSON.parse(content);
// Remove _id from update (can't update _id)
const { _id, ...updateFields } = updatedDoc;
@@ -243,6 +226,7 @@ export class TsviewMongoDocument extends DeesElement {
);
this.editing = false;
this.hasChanges = false;
await this.loadDocument();
this.dispatchEvent(
@@ -283,20 +267,6 @@ export class TsviewMongoDocument extends DeesElement {
}
}
private formatJson(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
private syntaxHighlight(json: string): string {
// Basic syntax highlighting
return json
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>')
.replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
.replace(/: (true|false)/g, ': <span class="json-boolean">$1</span>')
.replace(/: (null)/g, ': <span class="json-null">$1</span>');
}
render() {
if (!this.documentId) {
return html`
@@ -334,10 +304,14 @@ export class TsviewMongoDocument extends DeesElement {
<span class="header-title">Document</span>
<div class="header-actions">
${this.editing
? html`
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
`
? this.hasChanges
? html`
<button class="action-btn" @click=${this.handleDiscard}>Discard</button>
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
`
: html`
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
`
: html`
<button class="action-btn" @click=${this.startEditing}>Edit</button>
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
@@ -346,20 +320,12 @@ export class TsviewMongoDocument extends DeesElement {
</div>
<div class="content">
${this.editing
? html`
<textarea
class="edit-area"
.value=${this.editContent}
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)}
></textarea>
`
: html`
<div
class="json-view"
.innerHTML=${this.syntaxHighlight(this.formatJson(this.document))}
></div>
`}
<dees-input-code
.value=${this.originalContent}
.language=${'json'}
.disabled=${!this.editing}
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
></dees-input-code>
</div>
</div>
`;

View File

@@ -23,6 +23,12 @@ export class TsviewS3Browser extends DeesElement {
@state()
private accessor refreshKey: number = 0;
@state()
private accessor previewWidth: number = 350;
@state()
private accessor isResizingPreview: boolean = false;
public static styles = [
cssManager.defaultStyles,
themeStyles,
@@ -104,12 +110,24 @@ export class TsviewS3Browser extends DeesElement {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
gap: 0;
overflow: hidden;
}
.content.has-preview {
grid-template-columns: 1fr 350px;
grid-template-columns: 1fr 4px var(--preview-width, 350px);
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.main-view {
@@ -122,6 +140,7 @@ export class TsviewS3Browser extends DeesElement {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
margin-left: 12px;
}
@media (max-width: 1024px) {
@@ -130,7 +149,8 @@ export class TsviewS3Browser extends DeesElement {
grid-template-columns: 1fr;
}
.preview-panel {
.preview-panel,
.resize-divider {
display: none;
}
}
@@ -168,6 +188,28 @@ export class TsviewS3Browser extends DeesElement {
}
}
private startPreviewResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingPreview = true;
document.addEventListener('mousemove', this.handlePreviewResize);
document.addEventListener('mouseup', this.endPreviewResize);
};
private handlePreviewResize = (e: MouseEvent) => {
if (!this.isResizingPreview) return;
const contentEl = this.shadowRoot?.querySelector('.content');
if (!contentEl) return;
const containerRect = contentEl.getBoundingClientRect();
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 600);
this.previewWidth = newWidth;
};
private endPreviewResize = () => {
this.isResizingPreview = false;
document.removeEventListener('mousemove', this.handlePreviewResize);
document.removeEventListener('mouseup', this.endPreviewResize);
};
render() {
const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean)
@@ -213,7 +255,7 @@ export class TsviewS3Browser extends DeesElement {
</div>
</div>
<div class="content ${this.selectedKey ? 'has-preview' : ''}">
<div class="content ${this.selectedKey ? 'has-preview' : ''}" style="--preview-width: ${this.previewWidth}px">
<div class="main-view">
${this.viewType === 'columns'
? html`
@@ -238,6 +280,10 @@ export class TsviewS3Browser extends DeesElement {
${this.selectedKey
? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel">
<tsview-s3-preview
.bucketName=${this.bucketName}

View File

@@ -35,6 +35,17 @@ export interface ICollectionStats {
indexCount: number;
}
export interface IDatabaseStats {
collections: number;
views: number;
objects: number;
avgObjSize: number;
dataSize: number;
storageSize: number;
indexes: number;
indexSize: number;
}
/**
* API service for communicating with the tsview backend
*/
@@ -344,4 +355,12 @@ export class ApiService {
}> {
return this.request('getServerStatus', {});
}
async getDatabaseStats(databaseName: string): Promise<IDatabaseStats | null> {
const result = await this.request<
{ databaseName: string },
{ stats: IDatabaseStats | null }
>('getDatabaseStats', { databaseName });
return result.stats;
}
}