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

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2026-01-25 - 1.4.0 - 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
- Add backend handler getDatabaseStats + request/response typings (IReq_GetDatabaseStats, IDatabaseStats) and ApiService.getDatabaseStats()
- New UI component tsview-mongo-db-overview to show database statistics (collections, objects, avg size, data/storage size, indexes)
- Collections list: added an "Overview" entry that opens the DB overview when selected
- Sidebar: context menu option to show/hide system databases (admin/config/local) and database collapse-on-click behavior
- Resizable layout improvements: draggable dividers added for sidebar, Mongo editor panel and S3 preview panel (persisted via local state variables)
- Document editor: switch to dees-input-code, track original content and unsaved changes, add discard/save flow and improved save handling
- S3 handlers: fastPut calls now pass overwrite: true to allow replacing existing keys
- Minor dependency bumps: @git.zone/tstest and @design.estate/dees-catalog
## 2026-01-25 - 1.3.0 - feat(s3) ## 2026-01-25 - 1.3.0 - feat(s3)
add S3 create file/folder dialogs and in-place text editor; export mongodb plugin add S3 create file/folder dialogs and in-place text editor; export mongodb plugin

View File

@@ -22,7 +22,7 @@
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.6", "@git.zone/tstest": "^3.1.7",
"@git.zone/tswatch": "3.0.1", "@git.zone/tswatch": "3.0.1",
"@types/node": "^25.0.10" "@types/node": "^25.0.10"
}, },
@@ -31,7 +31,7 @@
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.3.0",
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.975.0",
"@design.estate/dees-catalog": "^3.37.0", "@design.estate/dees-catalog": "^3.37.1",
"@design.estate/dees-element": "^2.1.5", "@design.estate/dees-element": "^2.1.5",
"@push.rocks/early": "^4.0.4", "@push.rocks/early": "^4.0.4",
"@push.rocks/npmextra": "^5.3.3", "@push.rocks/npmextra": "^5.3.3",

37
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^3.975.0 specifier: ^3.975.0
version: 3.975.0 version: 3.975.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.37.0 specifier: ^3.37.1
version: 3.37.0(@tiptap/pm@2.27.2) version: 3.37.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.1.5 specifier: ^2.1.5
version: 2.1.5 version: 2.1.5
@@ -76,8 +76,8 @@ importers:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
'@git.zone/tstest': '@git.zone/tstest':
specifier: ^3.1.6 specifier: ^3.1.7
version: 3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3) version: 3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
'@git.zone/tswatch': '@git.zone/tswatch':
specifier: 3.0.1 specifier: 3.0.1
version: 3.0.1(@tiptap/pm@2.27.2) version: 3.0.1(@tiptap/pm@2.27.2)
@@ -319,11 +319,14 @@ packages:
'@cloudflare/workers-types@4.20260123.0': '@cloudflare/workers-types@4.20260123.0':
resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==} resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==}
'@cloudflare/workers-types@4.20260124.0':
resolution: {integrity: sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA==}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.37.0': '@design.estate/dees-catalog@3.37.1':
resolution: {integrity: sha512-c6q+yK2FwMsMK72GykUhZnvKUgTzjFO9vdbn6OBxas2/eY/6Wi6BC5i9YONN0UYcW8yqjHIDjN9nP7yE1Ai4PA==} resolution: {integrity: sha512-NCgzzCG3NJVF7C7aa1nExCMhB+7nA6glFgZpsff32CpvdtbAuBQiuOngU0suVw65uK7Y0a2r/y2CEPGNNmj3TA==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -538,8 +541,8 @@ packages:
resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==} resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==}
hasBin: true hasBin: true
'@git.zone/tstest@3.1.6': '@git.zone/tstest@3.1.7':
resolution: {integrity: sha512-xRGc6wO4rJ6mohPCMIBDRH+oNjiIvX6Jeo8v/Y5o5VyKSHFmqol7FCKSBrojMcqgBpESnLHFPJAAOmT9W3JV8Q==} resolution: {integrity: sha512-YCDA+65LJhoY3WJxrNduKlpGf37aq4bFe+fdRqE0dZ2W1f7j3sUunBaBzckShSHKRjkMdPZKr0W0sXFXUK/PcA==}
hasBin: true hasBin: true
'@git.zone/tswatch@3.0.1': '@git.zone/tswatch@3.0.1':
@@ -2805,8 +2808,8 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'} engines: {node: '>=12'}
lucide@0.562.0: lucide@0.563.0:
resolution: {integrity: sha512-k1Fb8ZMnRQovWRlea7Jr0b9UKA29IM7/cu79+mJrhVohvA2YC/Ti3Sk+G+h/SIu3IlrKT4RAbWMHUBBQd1O6XA==} resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
make-dir@3.1.0: make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -4008,7 +4011,7 @@ snapshots:
'@api.global/typedrequest': 3.2.5 '@api.global/typedrequest': 3.2.5
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 3.1.1(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 3.1.1(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260123.0 '@cloudflare/workers-types': 4.20260124.0
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.2.0 '@push.rocks/smartchok': 1.2.0
@@ -4057,7 +4060,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260123.0 '@cloudflare/workers-types': 4.20260123.0
'@design.estate/dees-catalog': 3.37.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.37.1(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4679,11 +4682,13 @@ snapshots:
'@cloudflare/workers-types@4.20260123.0': {} '@cloudflare/workers-types@4.20260123.0': {}
'@cloudflare/workers-types@4.20260124.0': {}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.37.0(@tiptap/pm@2.27.2)': '@design.estate/dees-catalog@3.37.1(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.3.7 '@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-element': 2.1.5 '@design.estate/dees-element': 2.1.5
@@ -4706,7 +4711,7 @@ snapshots:
apexcharts: 5.3.6 apexcharts: 5.3.6
highlight.js: 11.11.1 highlight.js: 11.11.1
ibantools: 4.5.1 ibantools: 4.5.1
lucide: 0.562.0 lucide: 0.563.0
monaco-editor: 0.55.1 monaco-editor: 0.55.1
pdfjs-dist: 4.10.38 pdfjs-dist: 4.10.38
xterm: 5.3.0 xterm: 5.3.0
@@ -4962,7 +4967,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
tsx: 4.21.0 tsx: 4.21.0
'@git.zone/tstest@3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)': '@git.zone/tstest@3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)':
dependencies: dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1) '@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbundle': 2.8.3 '@git.zone/tsbundle': 2.8.3
@@ -8106,7 +8111,7 @@ snapshots:
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lucide@0.562.0: {} lucide@0.563.0: {}
make-dir@3.1.0: make-dir@3.1.0:
dependencies: dependencies:

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', 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' description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
} }

View File

@@ -53,6 +53,37 @@ export async function registerMongoHandlers(
) )
); );
// Get database stats
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetDatabaseStats>(
'getDatabaseStats',
async (reqData) => {
try {
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
const stats = await db.stats();
const collections = await db.listCollections().toArray();
return {
stats: {
collections: collections.length,
views: stats.views || 0,
objects: stats.objects || 0,
avgObjSize: stats.avgObjSize || 0,
dataSize: stats.dataSize || 0,
storageSize: stats.storageSize || 0,
indexes: stats.indexes || 0,
indexSize: stats.indexSize || 0,
},
};
} catch (err) {
console.error('Error getting database stats:', err);
return { stats: null };
}
}
)
);
// List collections // List collections
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>( new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>(

View File

@@ -292,6 +292,7 @@ export async function registerS3Handlers(
await bucket.fastPut({ await bucket.fastPut({
path: reqData.key, path: reqData.key,
contents: content, contents: content,
overwrite: true,
}); });
return { success: true }; return { success: true };
@@ -354,6 +355,7 @@ export async function registerS3Handlers(
await destBucket.fastPut({ await destBucket.fastPut({
path: reqData.destKey, path: reqData.destKey,
contents: content, contents: content,
overwrite: true,
}); });
return { success: true }; return { success: true };

File diff suppressed because one or more lines are too long

View File

@@ -511,3 +511,27 @@ export interface IReq_GetServerStatus extends plugins.typedrequestInterfaces.imp
}; };
}; };
} }
export interface IDatabaseStats {
collections: number;
views: number;
objects: number;
avgObjSize: number;
dataSize: number;
storageSize: number;
indexes: number;
indexSize: number;
}
export interface IReq_GetDatabaseStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDatabaseStats
> {
method: 'getDatabaseStats';
request: {
databaseName: string;
};
response: {
stats: IDatabaseStats | null;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', 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' 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-documents.js';
export * from './tsview-mongo-document.js'; export * from './tsview-mongo-document.js';
export * from './tsview-mongo-indexes.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() @state()
private accessor newDatabaseName: string = ''; private accessor newDatabaseName: string = '';
@state()
private accessor showSystemDatabases: boolean = false;
@state() @state()
private accessor showS3CreateDialog: boolean = false; private accessor showS3CreateDialog: boolean = false;
@@ -57,6 +60,12 @@ export class TsviewApp extends DeesElement {
@state() @state()
private accessor s3CreateDialogName: string = ''; private accessor s3CreateDialogName: string = '';
@state()
private accessor sidebarWidth: number = 240;
@state()
private accessor isResizingSidebar: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -130,10 +139,22 @@ export class TsviewApp extends DeesElement {
.app-main { .app-main {
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: var(--sidebar-width, 240px) 4px 1fr;
overflow: hidden; 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 { .sidebar {
background: #1e1e1e; background: #1e1e1e;
border-right: 1px solid #333; 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() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await this.loadData(); await this.loadData();
@@ -423,8 +453,15 @@ export class TsviewApp extends DeesElement {
} }
private selectDatabase(db: string) { private selectDatabase(db: string) {
this.selectedDatabase = db; if (this.selectedDatabase === db) {
this.selectedCollection = ''; // 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) { 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() { render() {
return html` return html`
<div class="app-container"> <div class="app-container">
@@ -669,8 +738,12 @@ export class TsviewApp extends DeesElement {
</nav> </nav>
</header> </header>
<main class="app-main"> <main class="app-main" style="--sidebar-width: ${this.sidebarWidth}px">
${this.renderSidebar()} ${this.renderSidebar()}
<div
class="resize-divider ${this.isResizingSidebar ? 'active' : ''}"
@mousedown=${this.startSidebarResize}
></div>
${this.renderContent()} ${this.renderContent()}
</main> </main>
</div> </div>
@@ -849,7 +922,7 @@ export class TsviewApp extends DeesElement {
if (this.viewMode === 'mongo') { if (this.viewMode === 'mongo') {
return html` return html`
<aside class="sidebar"> <aside class="sidebar" @contextmenu=${(e: MouseEvent) => this.handleSidebarContextMenu(e)}>
<div class="sidebar-header">Databases & Collections</div> <div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}> <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"> <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 New Database
</button> </button>
<div class="sidebar-list"> <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>` ? 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> </div>
</aside> </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` return html`
<div class="content-area"> <div class="content-area">
<tsview-mongo-browser <tsview-mongo-browser

View File

@@ -24,6 +24,12 @@ export class TsviewMongoBrowser extends DeesElement {
@state() @state()
private accessor stats: ICollectionStats | null = null; private accessor stats: ICollectionStats | null = null;
@state()
private accessor editorWidth: number = 400;
@state()
private accessor isResizingEditor: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -102,11 +108,23 @@ export class TsviewMongoBrowser extends DeesElement {
.content { .content {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 4px var(--editor-width, 400px);
gap: 16px; gap: 0;
overflow: hidden; 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 { .main-panel {
overflow: auto; overflow: auto;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
@@ -117,6 +135,7 @@ export class TsviewMongoBrowser extends DeesElement {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
margin-left: 12px;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -124,7 +143,8 @@ export class TsviewMongoBrowser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-panel { .detail-panel,
.resize-divider {
display: none; display: none;
} }
} }
@@ -162,6 +182,28 @@ export class TsviewMongoBrowser extends DeesElement {
this.selectedDocumentId = e.detail.documentId; 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() { render() {
return html` return html`
<div class="browser-container"> <div class="browser-container">
@@ -201,7 +243,7 @@ export class TsviewMongoBrowser extends DeesElement {
</div> </div>
</div> </div>
<div class="content"> <div class="content" style="--editor-width: ${this.editorWidth}px">
<div class="main-panel"> <div class="main-panel">
${this.activeTab === 'documents' ${this.activeTab === 'documents'
? html` ? html`
@@ -225,6 +267,11 @@ export class TsviewMongoBrowser extends DeesElement {
`} `}
</div> </div>
<div
class="resize-divider ${this.isResizingEditor ? 'active' : ''}"
@mousedown=${this.startEditorResize}
></div>
<div class="detail-panel"> <div class="detail-panel">
<tsview-mongo-document <tsview-mongo-document
.databaseName=${this.databaseName} .databaseName=${this.databaseName}

View File

@@ -90,6 +90,33 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px; font-size: 12px;
font-style: italic; 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(); await this.loadCollections();
} }
private selectOverview() {
this.dispatchEvent(
new CustomEvent('collection-selected', {
detail: '__overview__',
bubbles: true,
composed: true,
})
);
}
render() { render() {
if (this.loading) { if (this.loading) {
return html`<div class="loading-state">Loading collections...</div>`; 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` return html`
<div class="collections-list"> <div class="collections-list">
${this.collections.map( <div
(coll) => html` class="overview-item ${this.selectedCollection === '__overview__' ? 'selected' : ''}"
<div @click=${() => this.selectOverview()}
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}" >
@click=${() => this.selectCollection(coll.name)} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)} <rect x="3" y="3" width="7" height="7"></rect>
> <rect x="14" y="3" width="7" height="7"></rect>
<span class="collection-name"> <rect x="14" y="14" width="7" height="7"></rect>
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <rect x="3" y="14" width="7" height="7"></rect>
<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>
</svg> Overview
${coll.name} </div>
</span> ${this.collections.length === 0
${coll.count !== undefined ? html`<div class="empty-state">No collections</div>`
? html`<span class="collection-count">${formatCount(coll.count)}</span>` : this.collections.map(
: ''} (coll) => html`
</div> <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> </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; private accessor editing: boolean = false;
@state() @state()
private accessor editContent: string = ''; private accessor originalContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state() @state()
private accessor error: string = ''; private accessor error: string = '';
@@ -101,56 +104,13 @@ export class TsviewMongoDocument extends DeesElement {
.content { .content {
flex: 1; flex: 1;
overflow: auto; overflow: hidden;
padding: 12px; display: flex;
flex-direction: column;
} }
.json-view { .content dees-input-code {
font-family: 'Monaco', 'Menlo', monospace; flex: 1;
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;
} }
.empty-state { .empty-state {
@@ -190,10 +150,12 @@ export class TsviewMongoDocument extends DeesElement {
updated(changedProperties: Map<string, unknown>) { updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('documentId')) { if (changedProperties.has('documentId')) {
this.editing = false; this.editing = false;
this.hasChanges = false;
if (this.documentId) { if (this.documentId) {
this.loadDocument(); this.loadDocument();
} else { } else {
this.document = null; this.document = null;
this.originalContent = '';
} }
} }
} }
@@ -210,6 +172,8 @@ export class TsviewMongoDocument extends DeesElement {
this.collectionName, this.collectionName,
this.documentId this.documentId
); );
this.originalContent = JSON.stringify(this.document, null, 2);
this.hasChanges = false;
} catch (err) { } catch (err) {
console.error('Error loading document:', err); console.error('Error loading document:', err);
this.error = 'Failed to load document'; this.error = 'Failed to load document';
@@ -219,18 +183,37 @@ export class TsviewMongoDocument extends DeesElement {
} }
private startEditing() { private startEditing() {
this.editContent = JSON.stringify(this.document, null, 2);
this.editing = true; this.editing = true;
} }
private cancelEditing() { private cancelEditing() {
this.editing = false; 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() { private async saveDocument() {
try { 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) // Remove _id from update (can't update _id)
const { _id, ...updateFields } = updatedDoc; const { _id, ...updateFields } = updatedDoc;
@@ -243,6 +226,7 @@ export class TsviewMongoDocument extends DeesElement {
); );
this.editing = false; this.editing = false;
this.hasChanges = false;
await this.loadDocument(); await this.loadDocument();
this.dispatchEvent( 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() { render() {
if (!this.documentId) { if (!this.documentId) {
return html` return html`
@@ -334,10 +304,14 @@ export class TsviewMongoDocument extends DeesElement {
<span class="header-title">Document</span> <span class="header-title">Document</span>
<div class="header-actions"> <div class="header-actions">
${this.editing ${this.editing
? html` ? this.hasChanges
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button> ? html`
<button class="action-btn primary" @click=${this.saveDocument}>Save</button> <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` : html`
<button class="action-btn" @click=${this.startEditing}>Edit</button> <button class="action-btn" @click=${this.startEditing}>Edit</button>
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button> <button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
@@ -346,20 +320,12 @@ export class TsviewMongoDocument extends DeesElement {
</div> </div>
<div class="content"> <div class="content">
${this.editing <dees-input-code
? html` .value=${this.originalContent}
<textarea .language=${'json'}
class="edit-area" .disabled=${!this.editing}
.value=${this.editContent} @content-change=${(e: CustomEvent) => this.handleContentChange(e)}
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)} ></dees-input-code>
></textarea>
`
: html`
<div
class="json-view"
.innerHTML=${this.syntaxHighlight(this.formatJson(this.document))}
></div>
`}
</div> </div>
</div> </div>
`; `;

View File

@@ -23,6 +23,12 @@ export class TsviewS3Browser extends DeesElement {
@state() @state()
private accessor refreshKey: number = 0; private accessor refreshKey: number = 0;
@state()
private accessor previewWidth: number = 350;
@state()
private accessor isResizingPreview: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -104,12 +110,24 @@ export class TsviewS3Browser extends DeesElement {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 0;
overflow: hidden; overflow: hidden;
} }
.content.has-preview { .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 { .main-view {
@@ -122,6 +140,7 @@ export class TsviewS3Browser extends DeesElement {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
margin-left: 12px;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -130,7 +149,8 @@ export class TsviewS3Browser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.preview-panel { .preview-panel,
.resize-divider {
display: none; 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() { render() {
const breadcrumbParts = this.currentPrefix const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean) ? this.currentPrefix.split('/').filter(Boolean)
@@ -213,7 +255,7 @@ export class TsviewS3Browser extends DeesElement {
</div> </div>
</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"> <div class="main-view">
${this.viewType === 'columns' ${this.viewType === 'columns'
? html` ? html`
@@ -238,6 +280,10 @@ export class TsviewS3Browser extends DeesElement {
${this.selectedKey ${this.selectedKey
? html` ? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel"> <div class="preview-panel">
<tsview-s3-preview <tsview-s3-preview
.bucketName=${this.bucketName} .bucketName=${this.bucketName}

View File

@@ -35,6 +35,17 @@ export interface ICollectionStats {
indexCount: number; 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 * API service for communicating with the tsview backend
*/ */
@@ -344,4 +355,12 @@ export class ApiService {
}> { }> {
return this.request('getServerStatus', {}); 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;
}
} }