4 Commits

29 changed files with 3172 additions and 160 deletions

View File

@@ -1,5 +1,27 @@
# Changelog # Changelog
## 2026-01-25 - 1.5.0 - feat(streaming)
add real-time streaming (MongoDB change streams & S3 bucket watchers) with WebSocket subscriptions and activity stream UI
- Server: add ChangeStreamManager to manage MongoDB change streams and S3 BucketWatcher subscriptions, handle subscription lifecycle, activity ring buffer and push events via TypedSocket.
- API: introduce streaming TypedRequest interfaces and register new handlers (subscribeMongo/unsubscribeMongo, subscribeS3/unsubscribeS3, subscribeActivity/unsubscribeActivity, getRecentActivity, push events).
- Client: add ChangeStreamService (TypedSocket connection, reconnection, RxJS subjects) and integrate into tsview-app, mongo and S3 browsers to show live status, change counters and refresh content on updates.
- UI: add new Activity Stream component (tsview-activity-stream), Activity tab and navigation, plus visual indicators (Live/Offline, change badges, auto-scroll and filters).
- Dependencies & plugins: add @api.global/typedsocket and @push.rocks/smartrx, bump @push.rocks/smartbucket; expose smartrx/typedsocket in plugins for server and web bundles.
- Docs: update readme.hints.md with Real-Time Streaming architecture, interfaces and dependency notes.
## 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

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tsview", "name": "@git.zone/tsview",
"version": "1.3.0", "version": "1.5.0",
"private": false, "private": false,
"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",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -19,11 +19,13 @@
"tsview": "cli.js" "tsview": "cli.js"
}, },
"devDependencies": { "devDependencies": {
"@api.global/typedsocket": "^4.1.0",
"@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",
"@push.rocks/smartrx": "^3.0.10",
"@types/node": "^25.0.10" "@types/node": "^25.0.10"
}, },
"dependencies": { "dependencies": {
@@ -31,11 +33,11 @@
"@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",
"@push.rocks/smartbucket": "^4.3.1", "@push.rocks/smartbucket": "^4.4.1",
"@push.rocks/smartcli": "^4.0.20", "@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfile": "^13.1.2",

55
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
@@ -33,8 +33,8 @@ importers:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.3.3 version: 5.3.3
'@push.rocks/smartbucket': '@push.rocks/smartbucket':
specifier: ^4.3.1 specifier: ^4.4.1
version: 4.3.1 version: 4.4.1
'@push.rocks/smartcli': '@push.rocks/smartcli':
specifier: ^4.0.20 specifier: ^4.0.20
version: 4.0.20 version: 4.0.20
@@ -66,6 +66,9 @@ importers:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(socks@2.8.7) version: 7.0.0(socks@2.8.7)
devDependencies: devDependencies:
'@api.global/typedsocket':
specifier: ^4.1.0
version: 4.1.0(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^4.1.2 specifier: ^4.1.2
version: 4.1.2 version: 4.1.2
@@ -76,11 +79,14 @@ 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)
'@push.rocks/smartrx':
specifier: ^3.0.10
version: 3.0.10
'@types/node': '@types/node':
specifier: ^25.0.10 specifier: ^25.0.10
version: 25.0.10 version: 25.0.10
@@ -319,11 +325,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 +547,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':
@@ -823,8 +832,8 @@ packages:
'@push.rocks/smartbucket@3.3.10': '@push.rocks/smartbucket@3.3.10':
resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==} resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==}
'@push.rocks/smartbucket@4.3.1': '@push.rocks/smartbucket@4.4.1':
resolution: {integrity: sha512-fMA8w98/E+usaaLkLm6wDj1XSpR0shTtG8AxTdwWIlH1YemQj/aCf4wReezDxUFVoUpC3HMzzV2RTFtQvHndeQ==} resolution: {integrity: sha512-68GFLgJKW+LXvuN+yuV8O/FozGMecraoT+PkI5whdRPFe7N3u2iYIHWAUjvQvVU4ygpdJv0kih2JDf5k3PYycw==}
'@push.rocks/smartbuffer@3.0.5': '@push.rocks/smartbuffer@3.0.5':
resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==} resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==}
@@ -2805,8 +2814,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 +4017,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 +4066,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 +4688,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 +4717,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 +4973,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
@@ -5522,7 +5533,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@push.rocks/smartbucket@4.3.1': '@push.rocks/smartbucket@4.4.1':
dependencies: dependencies:
'@aws-sdk/client-s3': 3.975.0 '@aws-sdk/client-s3': 3.975.0
'@push.rocks/smartmime': 2.0.4 '@push.rocks/smartmime': 2.0.4
@@ -5978,7 +5989,7 @@ snapshots:
'@push.rocks/smarts3@3.0.3': '@push.rocks/smarts3@3.0.3':
dependencies: dependencies:
'@push.rocks/smartbucket': 4.3.1 '@push.rocks/smartbucket': 4.4.1
'@push.rocks/smartfs': 1.3.1 '@push.rocks/smartfs': 1.3.1
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartxml': 2.0.0 '@push.rocks/smartxml': 2.0.0
@@ -8106,7 +8117,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

@@ -53,3 +53,47 @@ typedrouter.addTypedHandler(
) )
); );
``` ```
## Real-Time Streaming (v1.5.0+)
### Architecture
- `ts/streaming/` - Backend streaming infrastructure
- `classes.changestream-manager.ts` - Manages MongoDB and S3 watchers
- `interfaces.streaming.ts` - TypedRequest interfaces for subscriptions
- `ts_web/services/changestream.service.ts` - Frontend WebSocket client
### MongoDB Change Streams
- Uses native MongoDB Change Streams via `SmartdataDb.mongoDbClient`
- Subscription per collection: `db/collection`
- Events: insert, update, delete, replace, drop
### S3 Change Detection
- Uses `@push.rocks/smartbucket` BucketWatcher (polling-based)
- Polling interval: 5 seconds
- Events: add, modify, delete
### Frontend Components
- `tsview-activity-stream.ts` - Combined activity view with filtering
- MongoDB/S3 browsers auto-subscribe to current resource
- Visual indicators for "Live" status and recent change count
### Key Streaming Interfaces
```typescript
// Subscribe to collection changes
IReq_SubscribeMongo: { database, collection } -> { success, subscriptionId }
// Subscribe to bucket changes
IReq_SubscribeS3: { bucket, prefix? } -> { success, subscriptionId }
// Subscribe to activity stream (all changes)
IReq_SubscribeActivity: {} -> { success, subscriptionId }
// Server pushes changes to subscribed clients
IReq_PushMongoChange: { event: IMongoChangeEvent } -> { received }
IReq_PushS3Change: { event: IS3ChangeEvent } -> { received }
IReq_PushActivityEvent: { event: IActivityEvent } -> { received }
```
### Dependencies Added
- `@api.global/typedsocket` - WebSocket client/server
- `@push.rocks/smartrx` - RxJS utilities

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.5.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,32 @@ 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;
};
}
// ===========================================
// Re-export streaming interfaces
// ===========================================
export * from '../streaming/interfaces.streaming.js';

View File

@@ -17,6 +17,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartopen from '@push.rocks/smartopen'; import * as smartopen from '@push.rocks/smartopen';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
export { export {
early, early,
@@ -31,6 +32,7 @@ export {
smartopen, smartopen,
smartpath, smartpath,
smartpromise, smartpromise,
smartrx,
}; };
// AWS S3 SDK for direct S3 operations // AWS S3 SDK for direct S3 operations

View File

@@ -1,7 +1,9 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { TsView } from '../tsview.classes.tsview.js'; import type { TsView } from '../tsview.classes.tsview.js';
import type * as interfaces from '../interfaces/index.js';
import { registerS3Handlers } from '../api/handlers.s3.js'; import { registerS3Handlers } from '../api/handlers.s3.js';
import { registerMongoHandlers } from '../api/handlers.mongodb.js'; import { registerMongoHandlers } from '../api/handlers.mongodb.js';
import { ChangeStreamManager } from '../streaming/index.js';
import { files as bundledUiFiles } from '../bundled_ui.js'; import { files as bundledUiFiles } from '../bundled_ui.js';
/** /**
@@ -11,6 +13,7 @@ export class ViewServer {
private tsview: TsView; private tsview: TsView;
private port: number; private port: number;
private typedServer: plugins.typedserver.TypedServer | null = null; private typedServer: plugins.typedserver.TypedServer | null = null;
private changeStreamManager: ChangeStreamManager | null = null;
public typedrouter: plugins.typedrequest.TypedRouter; public typedrouter: plugins.typedrequest.TypedRouter;
constructor(tsview: TsView, port: number) { constructor(tsview: TsView, port: number) {
@@ -41,14 +44,179 @@ export class ViewServer {
await registerMongoHandlers(this.typedServer.typedrouter, this.tsview); await registerMongoHandlers(this.typedServer.typedrouter, this.tsview);
} }
// Initialize ChangeStreamManager for real-time updates
this.changeStreamManager = new ChangeStreamManager(this.tsview);
// Register streaming handlers
await this.registerStreamingHandlers();
// Start server // Start server
await this.typedServer.start(); await this.typedServer.start();
// Set TypedSocket reference after server starts
if (this.typedServer.typedsocket) {
this.changeStreamManager.setTypedSocket(this.typedServer.typedsocket);
}
}
/**
* Register WebSocket streaming handlers
*/
private async registerStreamingHandlers(): Promise<void> {
if (!this.typedServer || !this.changeStreamManager) return;
const typedrouter = this.typedServer.typedrouter;
// Subscribe to MongoDB collection changes
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_SubscribeMongo>(
'subscribeMongo',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false, subscriptionId: '' };
}
const result = await this.changeStreamManager!.subscribeToMongo(
connectionId,
reqData.database,
reqData.collection
);
return result;
}
)
);
// Unsubscribe from MongoDB collection changes
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_UnsubscribeMongo>(
'unsubscribeMongo',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false };
}
const success = await this.changeStreamManager!.unsubscribeFromMongo(
connectionId,
reqData.database,
reqData.collection
);
return { success };
}
)
);
// Subscribe to S3 bucket changes
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_SubscribeS3>(
'subscribeS3',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false, subscriptionId: '' };
}
const result = await this.changeStreamManager!.subscribeToS3(
connectionId,
reqData.bucket,
reqData.prefix
);
return result;
}
)
);
// Unsubscribe from S3 bucket changes
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_UnsubscribeS3>(
'unsubscribeS3',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false };
}
const success = await this.changeStreamManager!.unsubscribeFromS3(
connectionId,
reqData.bucket,
reqData.prefix
);
return { success };
}
)
);
// Subscribe to activity stream
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_SubscribeActivity>(
'subscribeActivity',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false, subscriptionId: '' };
}
const result = await this.changeStreamManager!.subscribeToActivity(connectionId);
return result;
}
)
);
// Unsubscribe from activity stream
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_UnsubscribeActivity>(
'unsubscribeActivity',
async (reqData, context) => {
const connectionId = this.getConnectionId(context);
if (!connectionId) {
return { success: false };
}
const success = await this.changeStreamManager!.unsubscribeFromActivity(connectionId);
return { success };
}
)
);
// Get recent activity events
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetRecentActivity>(
'getRecentActivity',
async (reqData) => {
const events = this.changeStreamManager!.getRecentActivity(reqData.limit || 100);
return { events };
}
)
);
}
/**
* Extract connection ID from request context
*/
private getConnectionId(context: any): string | null {
// Try to get connection ID from WebSocket context
if (context?.socketConnection?.socketId) {
return context.socketConnection.socketId;
}
if (context?.socketConnection?.alias) {
return context.socketConnection.alias;
}
// Fallback: generate a unique ID for HTTP requests
// Note: Real-time streaming requires WebSocket connection
return null;
} }
/** /**
* Stop the server * Stop the server
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
// Stop change stream manager first
if (this.changeStreamManager) {
await this.changeStreamManager.stop();
this.changeStreamManager = null;
}
if (this.typedServer) { if (this.typedServer) {
await this.typedServer.stop(); await this.typedServer.stop();
this.typedServer = null; this.typedServer = null;

View File

@@ -0,0 +1,591 @@
import * as plugins from '../plugins.js';
import type { TsView } from '../tsview.classes.tsview.js';
import type * as interfaces from './interfaces.streaming.js';
import type { IS3ChangeEvent } from '@push.rocks/smartbucket';
/**
* Subscription entry tracking a client's subscription to a resource
*/
interface ISubscriptionEntry {
connectionId: string;
subscriptionId: string;
createdAt: Date;
}
/**
* MongoDB watcher entry
*/
interface IMongoWatcherEntry {
watcher: plugins.mongodb.ChangeStream;
subscriptions: Map<string, ISubscriptionEntry>; // connectionId -> subscription
}
/**
* S3 watcher entry
*/
interface IS3WatcherEntry {
watcher: plugins.smartbucket.BucketWatcher;
subscriptions: Map<string, ISubscriptionEntry>; // connectionId -> subscription
}
/**
* ChangeStreamManager manages real-time change streaming for both MongoDB and S3.
*
* Features:
* - MongoDB Change Streams for real-time database updates
* - S3 BucketWatcher for polling-based S3 change detection
* - Subscription management per WebSocket client
* - Activity stream with ring buffer for recent events
* - Automatic cleanup on client disconnect
*/
export class ChangeStreamManager {
private tsview: TsView;
private typedSocket: plugins.typedserver.TypedServer['typedsocket'] | null = null;
// MongoDB watchers: "db/collection" -> watcher entry
private mongoWatchers: Map<string, IMongoWatcherEntry> = new Map();
// S3 watchers: "bucket/prefix" -> watcher entry
private s3Watchers: Map<string, IS3WatcherEntry> = new Map();
// Activity subscribers: connectionId -> subscription entry
private activitySubscribers: Map<string, ISubscriptionEntry> = new Map();
// Activity ring buffer (max 1000 events)
private activityBuffer: interfaces.IActivityEvent[] = [];
private readonly ACTIVITY_BUFFER_SIZE = 1000;
// Counter for generating unique subscription IDs
private subscriptionCounter = 0;
constructor(tsview: TsView) {
this.tsview = tsview;
}
/**
* Initialize the manager with a TypedSocket instance
*/
public setTypedSocket(typedSocket: plugins.typedserver.TypedServer['typedsocket']): void {
this.typedSocket = typedSocket;
}
/**
* Generate a unique subscription ID
*/
private generateSubscriptionId(): string {
return `sub_${Date.now()}_${++this.subscriptionCounter}`;
}
/**
* Get the MongoDB key for a database/collection pair
*/
private getMongoKey(database: string, collection: string): string {
return `${database}/${collection}`;
}
/**
* Get the S3 key for a bucket/prefix pair
*/
private getS3Key(bucket: string, prefix?: string): string {
return prefix ? `${bucket}/${prefix}` : bucket;
}
// ===========================================
// MongoDB Change Streams
// ===========================================
/**
* Subscribe a client to MongoDB collection changes
*/
public async subscribeToMongo(
connectionId: string,
database: string,
collection: string
): Promise<{ success: boolean; subscriptionId: string }> {
const key = this.getMongoKey(database, collection);
let entry = this.mongoWatchers.get(key);
// Create watcher if it doesn't exist
if (!entry) {
const watcher = await this.createMongoWatcher(database, collection);
if (!watcher) {
return { success: false, subscriptionId: '' };
}
entry = {
watcher,
subscriptions: new Map(),
};
this.mongoWatchers.set(key, entry);
}
// Add subscription
const subscriptionId = this.generateSubscriptionId();
entry.subscriptions.set(connectionId, {
connectionId,
subscriptionId,
createdAt: new Date(),
});
console.log(`[ChangeStream] MongoDB subscription added: ${key} for connection ${connectionId}`);
return { success: true, subscriptionId };
}
/**
* Unsubscribe a client from MongoDB collection changes
*/
public async unsubscribeFromMongo(
connectionId: string,
database: string,
collection: string
): Promise<boolean> {
const key = this.getMongoKey(database, collection);
const entry = this.mongoWatchers.get(key);
if (!entry) {
return false;
}
entry.subscriptions.delete(connectionId);
console.log(`[ChangeStream] MongoDB subscription removed: ${key} for connection ${connectionId}`);
// Close watcher if no more subscribers
if (entry.subscriptions.size === 0) {
await this.closeMongoWatcher(key);
}
return true;
}
/**
* Create a MongoDB change stream for a collection
*/
private async createMongoWatcher(
database: string,
collection: string
): Promise<plugins.mongodb.ChangeStream | null> {
try {
const db = await this.tsview.getMongoDb();
if (!db) {
console.error('[ChangeStream] MongoDB not configured');
return null;
}
const client = (db as any).mongoDbClient;
const mongoDb = client.db(database);
const mongoCollection = mongoDb.collection(collection);
// Create change stream
const changeStream = mongoCollection.watch([], {
fullDocument: 'updateLookup',
});
// Handle change events
changeStream.on('change', (change: any) => {
this.handleMongoChange(database, collection, change);
});
changeStream.on('error', (error: Error) => {
console.error(`[ChangeStream] MongoDB error for ${database}/${collection}:`, error);
});
console.log(`[ChangeStream] MongoDB watcher created for ${database}/${collection}`);
return changeStream;
} catch (error) {
console.error(`[ChangeStream] Failed to create MongoDB watcher for ${database}/${collection}:`, error);
return null;
}
}
/**
* Handle a MongoDB change event
*/
private handleMongoChange(database: string, collection: string, change: any): void {
const key = this.getMongoKey(database, collection);
const entry = this.mongoWatchers.get(key);
if (!entry) return;
// Convert MongoDB change event to our interface
const event: interfaces.IMongoChangeEvent = {
type: change.operationType as interfaces.IMongoChangeEvent['type'],
database,
collection,
documentId: change.documentKey?._id?.toString(),
document: change.fullDocument,
updateDescription: change.updateDescription,
timestamp: new Date().toISOString(),
};
// Add to activity buffer
this.addToActivityBuffer('mongodb', event);
// Push to all subscribed clients
this.pushMongoChangeToClients(key, event);
}
/**
* Push MongoDB change to subscribed clients
*/
private async pushMongoChangeToClients(
key: string,
event: interfaces.IMongoChangeEvent
): Promise<void> {
const entry = this.mongoWatchers.get(key);
if (!entry || !this.typedSocket) return;
for (const [connectionId, _sub] of entry.subscriptions) {
try {
// Find the connection and push the event
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
});
if (connection) {
const request = this.typedSocket.createTypedRequest<interfaces.IReq_PushMongoChange>(
'pushMongoChange',
connection
);
await request.fire({ event });
}
} catch (error) {
console.error(`[ChangeStream] Failed to push MongoDB change to ${connectionId}:`, error);
}
}
}
/**
* Close a MongoDB change stream
*/
private async closeMongoWatcher(key: string): Promise<void> {
const entry = this.mongoWatchers.get(key);
if (!entry) return;
try {
await entry.watcher.close();
this.mongoWatchers.delete(key);
console.log(`[ChangeStream] MongoDB watcher closed for ${key}`);
} catch (error) {
console.error(`[ChangeStream] Error closing MongoDB watcher for ${key}:`, error);
}
}
// ===========================================
// S3 Change Watching
// ===========================================
/**
* Subscribe a client to S3 bucket/prefix changes
*/
public async subscribeToS3(
connectionId: string,
bucket: string,
prefix?: string
): Promise<{ success: boolean; subscriptionId: string }> {
const key = this.getS3Key(bucket, prefix);
let entry = this.s3Watchers.get(key);
// Create watcher if it doesn't exist
if (!entry) {
const watcher = await this.createS3Watcher(bucket, prefix);
if (!watcher) {
return { success: false, subscriptionId: '' };
}
entry = {
watcher,
subscriptions: new Map(),
};
this.s3Watchers.set(key, entry);
}
// Add subscription
const subscriptionId = this.generateSubscriptionId();
entry.subscriptions.set(connectionId, {
connectionId,
subscriptionId,
createdAt: new Date(),
});
console.log(`[ChangeStream] S3 subscription added: ${key} for connection ${connectionId}`);
return { success: true, subscriptionId };
}
/**
* Unsubscribe a client from S3 bucket/prefix changes
*/
public async unsubscribeFromS3(
connectionId: string,
bucket: string,
prefix?: string
): Promise<boolean> {
const key = this.getS3Key(bucket, prefix);
const entry = this.s3Watchers.get(key);
if (!entry) {
return false;
}
entry.subscriptions.delete(connectionId);
console.log(`[ChangeStream] S3 subscription removed: ${key} for connection ${connectionId}`);
// Close watcher if no more subscribers
if (entry.subscriptions.size === 0) {
await this.closeS3Watcher(key);
}
return true;
}
/**
* Create an S3 bucket watcher
*/
private async createS3Watcher(
bucket: string,
prefix?: string
): Promise<plugins.smartbucket.BucketWatcher | null> {
try {
const smartbucket = await this.tsview.getSmartBucket();
if (!smartbucket) {
console.error('[ChangeStream] S3 not configured');
return null;
}
const bucketInstance = await smartbucket.getBucketByName(bucket);
// Create watcher using smartbucket's BucketWatcher
const watcher = bucketInstance.createWatcher({
prefix: prefix || '',
pollIntervalMs: 5000,
bufferTimeMs: 500,
});
// Subscribe to change events
watcher.changeSubject.subscribe((eventOrEvents: IS3ChangeEvent | IS3ChangeEvent[]) => {
const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents];
for (const event of events) {
this.handleS3Change(bucket, prefix, event);
}
});
// Start the watcher
await watcher.start();
await watcher.readyDeferred.promise;
console.log(`[ChangeStream] S3 watcher created for ${bucket}${prefix ? '/' + prefix : ''}`);
return watcher;
} catch (error) {
console.error(`[ChangeStream] Failed to create S3 watcher for ${bucket}:`, error);
return null;
}
}
/**
* Handle an S3 change event
*/
private handleS3Change(bucket: string, prefix: string | undefined, event: IS3ChangeEvent): void {
const key = this.getS3Key(bucket, prefix);
const entry = this.s3Watchers.get(key);
if (!entry) return;
// Add to activity buffer
this.addToActivityBuffer('s3', event);
// Push to all subscribed clients
this.pushS3ChangeToClients(key, event);
}
/**
* Push S3 change to subscribed clients
*/
private async pushS3ChangeToClients(
key: string,
event: IS3ChangeEvent
): Promise<void> {
const entry = this.s3Watchers.get(key);
if (!entry || !this.typedSocket) return;
for (const [connectionId, _sub] of entry.subscriptions) {
try {
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
});
if (connection) {
const request = this.typedSocket.createTypedRequest<interfaces.IReq_PushS3Change>(
'pushS3Change',
connection
);
await request.fire({ event });
}
} catch (error) {
console.error(`[ChangeStream] Failed to push S3 change to ${connectionId}:`, error);
}
}
}
/**
* Close an S3 bucket watcher
*/
private async closeS3Watcher(key: string): Promise<void> {
const entry = this.s3Watchers.get(key);
if (!entry) return;
try {
await entry.watcher.stop();
this.s3Watchers.delete(key);
console.log(`[ChangeStream] S3 watcher closed for ${key}`);
} catch (error) {
console.error(`[ChangeStream] Error closing S3 watcher for ${key}:`, error);
}
}
// ===========================================
// Activity Stream
// ===========================================
/**
* Subscribe a client to the activity stream
*/
public async subscribeToActivity(connectionId: string): Promise<{ success: boolean; subscriptionId: string }> {
const subscriptionId = this.generateSubscriptionId();
this.activitySubscribers.set(connectionId, {
connectionId,
subscriptionId,
createdAt: new Date(),
});
console.log(`[ChangeStream] Activity subscription added for connection ${connectionId}`);
return { success: true, subscriptionId };
}
/**
* Unsubscribe a client from the activity stream
*/
public async unsubscribeFromActivity(connectionId: string): Promise<boolean> {
const result = this.activitySubscribers.delete(connectionId);
if (result) {
console.log(`[ChangeStream] Activity subscription removed for connection ${connectionId}`);
}
return result;
}
/**
* Get recent activity events
*/
public getRecentActivity(limit: number = 100): interfaces.IActivityEvent[] {
const count = Math.min(limit, this.activityBuffer.length);
return this.activityBuffer.slice(-count);
}
/**
* Add an event to the activity buffer
*/
private addToActivityBuffer(
source: 'mongodb' | 's3',
event: interfaces.IMongoChangeEvent | IS3ChangeEvent
): void {
const activityEvent: interfaces.IActivityEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
source,
event,
timestamp: new Date().toISOString(),
};
this.activityBuffer.push(activityEvent);
// Trim buffer if it exceeds max size
if (this.activityBuffer.length > this.ACTIVITY_BUFFER_SIZE) {
this.activityBuffer = this.activityBuffer.slice(-this.ACTIVITY_BUFFER_SIZE);
}
// Push to activity subscribers
this.pushActivityToClients(activityEvent);
}
/**
* Push activity event to subscribed clients
*/
private async pushActivityToClients(event: interfaces.IActivityEvent): Promise<void> {
if (!this.typedSocket || this.activitySubscribers.size === 0) return;
for (const [connectionId, _sub] of this.activitySubscribers) {
try {
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
});
if (connection) {
const request = this.typedSocket.createTypedRequest<interfaces.IReq_PushActivityEvent>(
'pushActivityEvent',
connection
);
await request.fire({ event });
}
} catch (error) {
console.error(`[ChangeStream] Failed to push activity to ${connectionId}:`, error);
}
}
}
// ===========================================
// Connection Management
// ===========================================
/**
* Handle client disconnect - clean up all subscriptions
*/
public async handleDisconnect(connectionId: string): Promise<void> {
console.log(`[ChangeStream] Cleaning up subscriptions for disconnected connection ${connectionId}`);
// Clean up MongoDB subscriptions
for (const [key, entry] of this.mongoWatchers) {
if (entry.subscriptions.has(connectionId)) {
entry.subscriptions.delete(connectionId);
if (entry.subscriptions.size === 0) {
await this.closeMongoWatcher(key);
}
}
}
// Clean up S3 subscriptions
for (const [key, entry] of this.s3Watchers) {
if (entry.subscriptions.has(connectionId)) {
entry.subscriptions.delete(connectionId);
if (entry.subscriptions.size === 0) {
await this.closeS3Watcher(key);
}
}
}
// Clean up activity subscription
this.activitySubscribers.delete(connectionId);
}
/**
* Stop all watchers and clean up
*/
public async stop(): Promise<void> {
console.log('[ChangeStream] Stopping all watchers...');
// Close all MongoDB watchers
for (const key of this.mongoWatchers.keys()) {
await this.closeMongoWatcher(key);
}
// Close all S3 watchers
for (const key of this.s3Watchers.keys()) {
await this.closeS3Watcher(key);
}
// Clear activity buffer and subscribers
this.activityBuffer = [];
this.activitySubscribers.clear();
console.log('[ChangeStream] All watchers stopped');
}
}

2
ts/streaming/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './interfaces.streaming.js';
export * from './classes.changestream-manager.js';

View File

@@ -0,0 +1,212 @@
import type * as plugins from '../plugins.js';
// Re-export S3 change event from smartbucket
export type { IS3ChangeEvent } from '@push.rocks/smartbucket';
/**
* MongoDB change event - wraps smartdata watcher output
*/
export interface IMongoChangeEvent {
type: 'insert' | 'update' | 'delete' | 'replace' | 'drop' | 'invalidate';
database: string;
collection: string;
documentId?: string;
document?: Record<string, unknown>;
updateDescription?: {
updatedFields?: Record<string, unknown>;
removedFields?: string[];
};
timestamp: string;
}
/**
* Combined activity event for the activity stream
*/
export interface IActivityEvent {
id: string;
source: 'mongodb' | 's3';
event: IMongoChangeEvent | import('@push.rocks/smartbucket').IS3ChangeEvent;
timestamp: string;
}
// ===========================================
// TypedRequest interfaces for streaming subscriptions
// ===========================================
/**
* Subscribe to MongoDB collection changes
*/
export interface IReq_SubscribeMongo extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SubscribeMongo
> {
method: 'subscribeMongo';
request: {
database: string;
collection: string;
};
response: {
success: boolean;
subscriptionId: string;
};
}
/**
* Unsubscribe from MongoDB collection changes
*/
export interface IReq_UnsubscribeMongo extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UnsubscribeMongo
> {
method: 'unsubscribeMongo';
request: {
database: string;
collection: string;
};
response: {
success: boolean;
};
}
/**
* Subscribe to S3 bucket/prefix changes
*/
export interface IReq_SubscribeS3 extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SubscribeS3
> {
method: 'subscribeS3';
request: {
bucket: string;
prefix?: string;
};
response: {
success: boolean;
subscriptionId: string;
};
}
/**
* Unsubscribe from S3 bucket/prefix changes
*/
export interface IReq_UnsubscribeS3 extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UnsubscribeS3
> {
method: 'unsubscribeS3';
request: {
bucket: string;
prefix?: string;
};
response: {
success: boolean;
};
}
/**
* Subscribe to activity stream (all changes from MongoDB and S3)
*/
export interface IReq_SubscribeActivity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SubscribeActivity
> {
method: 'subscribeActivity';
request: {};
response: {
success: boolean;
subscriptionId: string;
};
}
/**
* Unsubscribe from activity stream
*/
export interface IReq_UnsubscribeActivity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UnsubscribeActivity
> {
method: 'unsubscribeActivity';
request: {};
response: {
success: boolean;
};
}
/**
* Get recent activity events (for initial load or reconnection)
*/
export interface IReq_GetRecentActivity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRecentActivity
> {
method: 'getRecentActivity';
request: {
limit?: number; // Default: 100
};
response: {
events: IActivityEvent[];
};
}
// ===========================================
// Server-to-client push event interfaces
// ===========================================
/**
* Server pushes MongoDB change to client
*/
export interface IReq_PushMongoChange extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushMongoChange
> {
method: 'pushMongoChange';
request: {
event: IMongoChangeEvent;
};
response: {
received: boolean;
};
}
/**
* Server pushes S3 change to client
*/
export interface IReq_PushS3Change extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushS3Change
> {
method: 'pushS3Change';
request: {
event: import('@push.rocks/smartbucket').IS3ChangeEvent;
};
response: {
received: boolean;
};
}
/**
* Server pushes activity event to client
*/
export interface IReq_PushActivityEvent extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushActivityEvent
> {
method: 'pushActivityEvent';
request: {
event: IActivityEvent;
};
response: {
received: boolean;
};
}
/**
* Connection tag for tracking subscriptions
*/
export interface ISubscriptionTag extends plugins.typedrequestInterfaces.ITag {
name: 'subscription';
payload: {
type: 'mongo' | 's3' | 'activity';
key: string; // e.g., "db/collection" or "bucket/prefix" or "activity"
};
}

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.5.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,7 @@ 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';
// Activity stream component
export * from './tsview-activity-stream.js';

View File

@@ -0,0 +1,561 @@
import * as plugins from '../plugins.js';
import { changeStreamService, type IActivityEvent, type IMongoChangeEvent, type IS3ChangeEvent } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
type TFilterMode = 'all' | 'mongodb' | 's3';
@customElement('tsview-activity-stream')
export class TsviewActivityStream extends DeesElement {
@state()
private accessor events: IActivityEvent[] = [];
@state()
private accessor filterMode: TFilterMode = 'all';
@state()
private accessor isConnected: boolean = false;
@state()
private accessor isLoading: boolean = true;
@state()
private accessor autoScroll: boolean = true;
private subscription: plugins.smartrx.rxjs.Subscription | null = null;
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
overflow: hidden;
}
.activity-container {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #333;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 12px;
}
.connection-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 400;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
.status-dot.disconnected {
background: #ef4444;
}
.status-dot.connecting {
background: #f59e0b;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-controls {
display: flex;
align-items: center;
gap: 12px;
}
.filter-tabs {
display: flex;
gap: 4px;
}
.filter-tab {
padding: 6px 12px;
background: transparent;
border: 1px solid #444;
color: #888;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.filter-tab:hover {
border-color: #666;
color: #aaa;
}
.filter-tab.active {
background: rgba(255, 255, 255, 0.1);
border-color: #666;
color: #e0e0e0;
}
.auto-scroll-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #888;
cursor: pointer;
}
.auto-scroll-toggle input {
cursor: pointer;
}
.events-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.event-item {
display: flex;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
}
.event-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.event-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.event-icon.mongodb {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.event-icon.s3 {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
.event-icon svg {
width: 18px;
height: 18px;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.event-title {
font-size: 13px;
font-weight: 500;
color: #e0e0e0;
}
.event-time {
font-size: 11px;
color: #666;
}
.event-details {
font-size: 12px;
color: #888;
font-family: monospace;
}
.event-type {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
margin-right: 8px;
}
.event-type.insert, .event-type.add {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.event-type.update, .event-type.modify {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.event-type.delete {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.event-type.replace {
background: rgba(168, 85, 247, 0.2);
color: #c084fc;
}
.event-type.drop, .event-type.invalidate {
background: rgba(239, 68, 68, 0.3);
color: #f87171;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
gap: 16px;
}
.empty-state svg {
width: 64px;
height: 64px;
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
}
.event-path {
color: #aaa;
word-break: break-all;
}
`,
];
async connectedCallback() {
super.connectedCallback();
await this.initializeStreaming();
}
disconnectedCallback() {
super.disconnectedCallback();
this.cleanup();
}
private async initializeStreaming() {
this.isLoading = true;
try {
// Connect to WebSocket if not connected
await changeStreamService.connect();
// Subscribe to connection status
this.connectionSubscription = changeStreamService.connectionStatus$.subscribe((status) => {
this.isConnected = status === 'connected';
});
// Subscribe to activity stream
await changeStreamService.subscribeToActivity();
// Load recent events
const recentEvents = await changeStreamService.getRecentActivity(100);
this.events = recentEvents;
// Subscribe to new events
this.subscription = changeStreamService.getActivityStream().subscribe((event) => {
this.events = [...this.events, event].slice(-500); // Keep last 500 events
// Auto-scroll if enabled
if (this.autoScroll) {
this.scrollToBottom();
}
});
this.isConnected = true;
} catch (error) {
console.error('Failed to initialize activity stream:', error);
this.isConnected = false;
}
this.isLoading = false;
}
private cleanup() {
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
}
if (this.connectionSubscription) {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = null;
}
changeStreamService.unsubscribeFromActivity();
}
private scrollToBottom() {
requestAnimationFrame(() => {
const list = this.shadowRoot?.querySelector('.events-list');
if (list) {
list.scrollTop = list.scrollHeight;
}
});
}
private setFilterMode(mode: TFilterMode) {
this.filterMode = mode;
}
private toggleAutoScroll() {
this.autoScroll = !this.autoScroll;
}
private get filteredEvents(): IActivityEvent[] {
if (this.filterMode === 'all') {
return this.events;
}
return this.events.filter((e) => e.source === this.filterMode);
}
private formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
private formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) {
return 'just now';
} else if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
return `${mins}m ago`;
} else if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
} else {
return date.toLocaleDateString();
}
}
private getEventTitle(event: IActivityEvent): string {
if (event.source === 'mongodb') {
const mongoEvent = event.event as IMongoChangeEvent;
return `${mongoEvent.database}.${mongoEvent.collection}`;
} else {
const s3Event = event.event as IS3ChangeEvent;
return s3Event.bucket;
}
}
private getEventDetails(event: IActivityEvent): string {
if (event.source === 'mongodb') {
const mongoEvent = event.event as IMongoChangeEvent;
if (mongoEvent.documentId) {
return `Document: ${mongoEvent.documentId}`;
}
return '';
} else {
const s3Event = event.event as IS3ChangeEvent;
return s3Event.key;
}
}
private getEventType(event: IActivityEvent): string {
return event.event.type;
}
private handleEventClick(event: IActivityEvent) {
// Dispatch navigation event
if (event.source === 'mongodb') {
const mongoEvent = event.event as IMongoChangeEvent;
this.dispatchEvent(
new CustomEvent('navigate-to-mongo', {
detail: {
database: mongoEvent.database,
collection: mongoEvent.collection,
documentId: mongoEvent.documentId,
},
bubbles: true,
composed: true,
})
);
} else {
const s3Event = event.event as IS3ChangeEvent;
this.dispatchEvent(
new CustomEvent('navigate-to-s3', {
detail: {
bucket: s3Event.bucket,
key: s3Event.key,
},
bubbles: true,
composed: true,
})
);
}
}
private renderMongoIcon() {
return html`
<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>
`;
}
private renderS3Icon() {
return html`
<svg 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>
`;
}
private getConnectionStatusText(): string {
if (this.isConnected) {
return 'Live';
}
return 'Disconnected';
}
render() {
return html`
<div class="activity-container">
<div class="header">
<div class="header-title">
Activity Stream
<div class="connection-status">
<span class="status-dot ${this.isConnected ? 'connected' : 'disconnected'}"></span>
${this.getConnectionStatusText()}
</div>
</div>
<div class="header-controls">
<div class="filter-tabs">
<button
class="filter-tab ${this.filterMode === 'all' ? 'active' : ''}"
@click=${() => this.setFilterMode('all')}
>
All
</button>
<button
class="filter-tab ${this.filterMode === 'mongodb' ? 'active' : ''}"
@click=${() => this.setFilterMode('mongodb')}
>
MongoDB
</button>
<button
class="filter-tab ${this.filterMode === 's3' ? 'active' : ''}"
@click=${() => this.setFilterMode('s3')}
>
S3
</button>
</div>
<label class="auto-scroll-toggle">
<input
type="checkbox"
.checked=${this.autoScroll}
@change=${this.toggleAutoScroll}
/>
Auto-scroll
</label>
</div>
</div>
<div class="events-list">
${this.isLoading
? html`<div class="loading-state">Connecting to activity stream...</div>`
: this.filteredEvents.length === 0
? html`
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<p>No activity yet. Changes will appear here in real-time.</p>
</div>
`
: this.filteredEvents.map(
(event) => html`
<div class="event-item" @click=${() => this.handleEventClick(event)}>
<div class="event-icon ${event.source}">
${event.source === 'mongodb' ? this.renderMongoIcon() : this.renderS3Icon()}
</div>
<div class="event-content">
<div class="event-header">
<div class="event-title">
<span class="event-type ${this.getEventType(event)}">${this.getEventType(event)}</span>
${this.getEventTitle(event)}
</div>
<div class="event-time" title=${this.formatTime(event.timestamp)}>
${this.formatRelativeTime(event.timestamp)}
</div>
</div>
<div class="event-details">
<span class="event-path">${this.getEventDetails(event)}</span>
</div>
</div>
</div>
`
)}
</div>
</div>
`;
}
}

View File

@@ -1,11 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService, changeStreamService } from '../services/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, state, DeesElement } = plugins; const { html, css, cssManager, customElement, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog; const { DeesContextmenu } = plugins.deesCatalog;
type TViewMode = 's3' | 'mongo' | 'settings'; type TViewMode = 's3' | 'mongo' | 'activity' | 'settings';
@customElement('tsview-app') @customElement('tsview-app')
export class TsviewApp extends DeesElement { export class TsviewApp extends DeesElement {
@@ -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,9 +410,29 @@ 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();
// Initialize WebSocket connection for real-time updates
this.initializeChangeStream();
}
private async initializeChangeStream() {
try {
await changeStreamService.connect();
console.log('[TsviewApp] ChangeStream connected');
} catch (error) {
console.warn('[TsviewApp] Failed to connect to ChangeStream:', error);
}
} }
private async loadData() { private async loadData() {
@@ -423,9 +464,16 @@ export class TsviewApp extends DeesElement {
} }
private selectDatabase(db: string) { private selectDatabase(db: string) {
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.selectedDatabase = db;
this.selectedCollection = ''; this.selectedCollection = '';
} }
}
private selectCollection(collection: string) { private selectCollection(collection: string) {
this.selectedCollection = collection; this.selectedCollection = collection;
@@ -635,6 +683,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">
@@ -660,6 +740,12 @@ export class TsviewApp extends DeesElement {
> >
MongoDB MongoDB
</button> </button>
<button
class="nav-tab ${this.viewMode === 'activity' ? 'active' : ''}"
@click=${() => this.setViewMode('activity')}
>
Activity
</button>
<button <button
class="nav-tab ${this.viewMode === 'settings' ? 'active' : ''}" class="nav-tab ${this.viewMode === 'settings' ? 'active' : ''}"
@click=${() => this.setViewMode('settings')} @click=${() => this.setViewMode('settings')}
@@ -669,8 +755,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 +939,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 +949,22 @@ 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>
</aside>
`;
}
if (this.viewMode === 'activity') {
return html`
<aside class="sidebar">
<div class="sidebar-header">Activity Stream</div>
<div class="sidebar-list">
<div class="sidebar-item" style="color: #888; font-size: 12px; cursor: default;">
Real-time changes from MongoDB and S3 appear here automatically.
</div>
</div> </div>
</aside> </aside>
`; `;
@@ -954,6 +1057,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
@@ -964,6 +1078,17 @@ export class TsviewApp extends DeesElement {
`; `;
} }
if (this.viewMode === 'activity') {
return html`
<div class="content-area" style="padding: 0;">
<tsview-activity-stream
@navigate-to-mongo=${this.handleNavigateToMongo}
@navigate-to-s3=${this.handleNavigateToS3}
></tsview-activity-stream>
</div>
`;
}
return html` return html`
<div class="content-area"> <div class="content-area">
<h2>Settings</h2> <h2>Settings</h2>
@@ -971,4 +1096,21 @@ export class TsviewApp extends DeesElement {
</div> </div>
`; `;
} }
private handleNavigateToMongo(e: CustomEvent) {
const { database, collection, documentId } = e.detail;
this.viewMode = 'mongo';
this.selectedDatabase = database;
this.selectedCollection = collection;
// If documentId is provided, we could potentially scroll to or highlight that document
// For now, just navigate to the collection
}
private handleNavigateToS3(e: CustomEvent) {
const { bucket, key } = e.detail;
this.viewMode = 's3';
this.selectedBucket = bucket;
// The S3 browser will need to be updated to navigate to the specific key
// For now, just navigate to the bucket
}
} }

View File

@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type ICollectionStats } from '../services/index.js'; import { apiService, changeStreamService, type ICollectionStats, type IMongoChangeEvent } from '../services/index.js';
import { formatSize, formatCount } from '../utilities/index.js'; import { formatSize, formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
@@ -24,6 +24,20 @@ 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;
@state()
private accessor recentChangeCount: number = 0;
@state()
private accessor isStreamConnected: boolean = false;
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -102,11 +116,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 +143,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,22 +151,119 @@ export class TsviewMongoBrowser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-panel { .detail-panel,
.resize-divider {
display: none; display: none;
} }
} }
.change-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(34, 197, 94, 0.2);
border-radius: 4px;
font-size: 11px;
color: #4ade80;
}
.change-indicator.pulse {
animation: pulse-green 1s ease-in-out;
}
@keyframes pulse-green {
0% { background: rgba(34, 197, 94, 0.4); }
100% { background: rgba(34, 197, 94, 0.2); }
}
.stream-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #888;
}
.stream-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #888;
}
.stream-dot.connected {
background: #22c55e;
}
`, `,
]; ];
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await this.loadStats(); await this.loadStats();
this.subscribeToChanges();
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeFromChanges();
} }
updated(changedProperties: Map<string, unknown>) { updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) { if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
this.loadStats(); this.loadStats();
this.selectedDocumentId = ''; this.selectedDocumentId = '';
this.recentChangeCount = 0;
// Re-subscribe to the new collection
this.unsubscribeFromChanges();
this.subscribeToChanges();
}
}
private async subscribeToChanges() {
if (!this.databaseName || !this.collectionName) return;
try {
// Subscribe to collection changes
const success = await changeStreamService.subscribeToCollection(this.databaseName, this.collectionName);
this.isStreamConnected = success;
if (success) {
// Listen for changes
this.changeSubscription = changeStreamService
.getCollectionChanges(this.databaseName, this.collectionName)
.subscribe((event) => {
this.handleChange(event);
});
}
} catch (error) {
console.warn('[MongoBrowser] Failed to subscribe to changes:', error);
this.isStreamConnected = false;
}
}
private unsubscribeFromChanges() {
if (this.changeSubscription) {
this.changeSubscription.unsubscribe();
this.changeSubscription = null;
}
if (this.databaseName && this.collectionName) {
changeStreamService.unsubscribeFromCollection(this.databaseName, this.collectionName);
}
this.isStreamConnected = false;
}
private handleChange(event: IMongoChangeEvent) {
console.log('[MongoBrowser] Received change:', event);
this.recentChangeCount++;
// Refresh stats to reflect changes
this.loadStats();
// Notify the documents component to refresh
const documentsEl = this.shadowRoot?.querySelector('tsview-mongo-documents') as any;
if (documentsEl?.refresh) {
documentsEl.refresh();
} }
} }
@@ -162,6 +286,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">
@@ -177,6 +323,17 @@ export class TsviewMongoBrowser extends DeesElement {
</div> </div>
` `
: ''} : ''}
<div class="stream-status">
<span class="stream-dot ${this.isStreamConnected ? 'connected' : ''}"></span>
${this.isStreamConnected ? 'Live' : 'Offline'}
</div>
${this.recentChangeCount > 0
? html`
<div class="change-indicator pulse">
${this.recentChangeCount} change${this.recentChangeCount > 1 ? 's' : ''}
</div>
`
: ''}
</div> </div>
<div class="tabs"> <div class="tabs">
@@ -201,7 +358,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 +382,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,18 +195,38 @@ 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
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` (coll) => html`
<div <div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}" class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"

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
? this.hasChanges
? html` ? html`
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button> <button class="action-btn" @click=${this.handleDiscard}>Discard</button>
<button class="action-btn primary" @click=${this.saveDocument}>Save</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

@@ -259,6 +259,13 @@ export class TsviewMongoDocuments extends DeesElement {
this.loading = false; this.loading = false;
} }
/**
* Public method to refresh documents (called by parent on change events)
*/
public async refresh() {
await this.loadDocuments();
}
private handleFilterInput(e: Event) { private handleFilterInput(e: Event) {
this.filterText = (e.target as HTMLInputElement).value; this.filterText = (e.target as HTMLInputElement).value;
} }

View File

@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService, changeStreamService, type IS3ChangeEvent } from '../services/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -23,6 +23,20 @@ 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;
@state()
private accessor recentChangeCount: number = 0;
@state()
private accessor isStreamConnected: boolean = false;
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -104,12 +118,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 +148,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,13 +157,66 @@ export class TsviewS3Browser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.preview-panel { .preview-panel,
.resize-divider {
display: none; display: none;
} }
} }
.stream-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #888;
margin-left: auto;
margin-right: 12px;
}
.stream-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #888;
}
.stream-dot.connected {
background: #22c55e;
}
.change-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(245, 158, 11, 0.2);
border-radius: 4px;
font-size: 11px;
color: #f59e0b;
margin-right: 12px;
}
.change-indicator.pulse {
animation: pulse-orange 1s ease-in-out;
}
@keyframes pulse-orange {
0% { background: rgba(245, 158, 11, 0.4); }
100% { background: rgba(245, 158, 11, 0.2); }
}
`, `,
]; ];
async connectedCallback() {
super.connectedCallback();
this.subscribeToChanges();
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeFromChanges();
}
private setViewType(type: TViewType) { private setViewType(type: TViewType) {
this.viewType = type; this.viewType = type;
} }
@@ -165,9 +245,76 @@ export class TsviewS3Browser extends DeesElement {
// Clear selection when bucket changes // Clear selection when bucket changes
this.selectedKey = ''; this.selectedKey = '';
this.currentPrefix = ''; this.currentPrefix = '';
this.recentChangeCount = 0;
// Re-subscribe to the new bucket
this.unsubscribeFromChanges();
this.subscribeToChanges();
} }
} }
private async subscribeToChanges() {
if (!this.bucketName) return;
try {
// Subscribe to bucket changes (with optional prefix)
const success = await changeStreamService.subscribeToBucket(this.bucketName, this.currentPrefix || undefined);
this.isStreamConnected = success;
if (success) {
// Listen for changes
this.changeSubscription = changeStreamService
.getBucketChanges(this.bucketName, this.currentPrefix || undefined)
.subscribe((event) => {
this.handleChange(event);
});
}
} catch (error) {
console.warn('[S3Browser] Failed to subscribe to changes:', error);
this.isStreamConnected = false;
}
}
private unsubscribeFromChanges() {
if (this.changeSubscription) {
this.changeSubscription.unsubscribe();
this.changeSubscription = null;
}
if (this.bucketName) {
changeStreamService.unsubscribeFromBucket(this.bucketName, this.currentPrefix || undefined);
}
this.isStreamConnected = false;
}
private handleChange(event: IS3ChangeEvent) {
console.log('[S3Browser] Received change:', event);
this.recentChangeCount++;
// Trigger refresh of child components
this.refreshKey++;
}
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)
@@ -197,6 +344,17 @@ export class TsviewS3Browser extends DeesElement {
})} })}
</div> </div>
<div class="stream-status">
<span class="stream-dot ${this.isStreamConnected ? 'connected' : ''}"></span>
${this.isStreamConnected ? 'Live' : 'Offline'}
</div>
${this.recentChangeCount > 0
? html`
<div class="change-indicator pulse">
${this.recentChangeCount} change${this.recentChangeCount > 1 ? 's' : ''}
</div>
`
: ''}
<div class="view-toggle"> <div class="view-toggle">
<button <button
class="view-btn ${this.viewType === 'columns' ? 'active' : ''}" class="view-btn ${this.viewType === 'columns' ? 'active' : ''}"
@@ -213,7 +371,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 +396,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

@@ -256,10 +256,15 @@ export class TsviewS3Preview extends DeesElement {
try { try {
const result = await apiService.getObject(this.bucketName, this.objectKey); const result = await apiService.getObject(this.bucketName, this.objectKey);
this.content = result.content; if (!result) {
this.contentType = result.contentType; this.error = 'Object not found';
this.size = result.size; this.loading = false;
this.lastModified = result.lastModified; return;
}
this.content = result.content || '';
this.contentType = result.contentType || '';
this.size = result.size || 0;
this.lastModified = result.lastModified || '';
// For text files, decode and store original content // For text files, decode and store original content
if (this.isText()) { if (this.isText()) {

View File

@@ -18,4 +18,9 @@ export const DeesElement = deesElement.DeesElement;
// @api.global scope // @api.global scope
import * as typedrequest from '@api.global/typedrequest'; import * as typedrequest from '@api.global/typedrequest';
export { typedrequest }; import * as typedsocket from '@api.global/typedsocket';
export { typedrequest, typedsocket };
// @push.rocks scope
import * as smartrx from '@push.rocks/smartrx';
export { smartrx };

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;
}
} }

View File

@@ -0,0 +1,521 @@
import * as plugins from '../plugins.js';
/**
* MongoDB change event
*/
export interface IMongoChangeEvent {
type: 'insert' | 'update' | 'delete' | 'replace' | 'drop' | 'invalidate';
database: string;
collection: string;
documentId?: string;
document?: Record<string, unknown>;
updateDescription?: {
updatedFields?: Record<string, unknown>;
removedFields?: string[];
};
timestamp: string;
}
/**
* S3 change event
*/
export interface IS3ChangeEvent {
type: 'add' | 'modify' | 'delete';
key: string;
size?: number;
etag?: string;
lastModified?: Date;
bucket: string;
}
/**
* Combined activity event
*/
export interface IActivityEvent {
id: string;
source: 'mongodb' | 's3';
event: IMongoChangeEvent | IS3ChangeEvent;
timestamp: string;
}
/**
* Subscription info tracked by the service
*/
interface ISubscription {
type: 'mongo' | 's3' | 'activity';
key: string; // "db/collection" or "bucket/prefix" or "activity"
subscriptionId: string;
}
/**
* ChangeStreamService manages real-time change subscriptions from the browser.
*
* Features:
* - WebSocket connection via TypedSocket
* - Automatic reconnection with subscription restoration
* - RxJS Subjects for reactive UI updates
* - Subscription lifecycle management
*/
export class ChangeStreamService {
private typedSocket: plugins.typedsocket.TypedSocket | null = null;
private isConnected = false;
private isConnecting = false;
private subscriptions: Map<string, ISubscription> = new Map();
// RxJS Subjects for UI components
public readonly mongoChanges$ = new plugins.smartrx.rxjs.Subject<IMongoChangeEvent>();
public readonly s3Changes$ = new plugins.smartrx.rxjs.Subject<IS3ChangeEvent>();
public readonly activityEvents$ = new plugins.smartrx.rxjs.Subject<IActivityEvent>();
public readonly connectionStatus$ = new plugins.smartrx.rxjs.ReplaySubject<'connected' | 'disconnected' | 'connecting'>(1);
constructor() {
// Emit initial disconnected status
this.connectionStatus$.next('disconnected');
}
/**
* Connect to the WebSocket server
*/
public async connect(): Promise<void> {
if (this.isConnected || this.isConnecting) {
return;
}
this.isConnecting = true;
this.connectionStatus$.next('connecting');
try {
// Create client router to handle server-initiated pushes
const clientRouter = new plugins.typedrequest.TypedRouter();
// Register handlers for server push events
this.registerPushHandlers(clientRouter);
// Connect to WebSocket server using current origin
this.typedSocket = await plugins.typedsocket.TypedSocket.createClient(
clientRouter,
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl()
);
this.isConnected = true;
this.isConnecting = false;
this.connectionStatus$.next('connected');
console.log('[ChangeStream] WebSocket connected');
// Handle reconnection events via statusSubject
this.typedSocket.statusSubject.subscribe((status) => {
if (status === 'disconnected') {
this.handleDisconnect();
} else if (status === 'connected') {
this.handleReconnect();
}
});
} catch (error) {
this.isConnecting = false;
this.connectionStatus$.next('disconnected');
console.error('[ChangeStream] Failed to connect:', error);
throw error;
}
}
/**
* Disconnect from the WebSocket server
*/
public async disconnect(): Promise<void> {
if (!this.typedSocket) {
return;
}
try {
await this.typedSocket.stop();
} catch (error) {
console.error('[ChangeStream] Error during disconnect:', error);
}
this.typedSocket = null;
this.isConnected = false;
this.subscriptions.clear();
this.connectionStatus$.next('disconnected');
console.log('[ChangeStream] WebSocket disconnected');
}
/**
* Register handlers for server push events
*/
private registerPushHandlers(router: plugins.typedrequest.TypedRouter): void {
// Handle MongoDB change push
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'pushMongoChange',
async (data: { event: IMongoChangeEvent }) => {
this.mongoChanges$.next(data.event);
return { received: true };
}
)
);
// Handle S3 change push
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'pushS3Change',
async (data: { event: IS3ChangeEvent }) => {
this.s3Changes$.next(data.event);
return { received: true };
}
)
);
// Handle activity event push
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'pushActivityEvent',
async (data: { event: IActivityEvent }) => {
this.activityEvents$.next(data.event);
return { received: true };
}
)
);
}
/**
* Handle WebSocket disconnection
*/
private handleDisconnect(): void {
this.isConnected = false;
this.connectionStatus$.next('disconnected');
console.log('[ChangeStream] WebSocket disconnected, waiting for reconnect...');
}
/**
* Handle WebSocket reconnection - restore all subscriptions
*/
private async handleReconnect(): Promise<void> {
this.isConnected = true;
this.connectionStatus$.next('connected');
console.log('[ChangeStream] WebSocket reconnected, restoring subscriptions...');
// Restore all subscriptions
const subscriptionsToRestore = Array.from(this.subscriptions.values());
this.subscriptions.clear();
for (const sub of subscriptionsToRestore) {
try {
if (sub.type === 'mongo') {
const [database, collection] = sub.key.split('/');
await this.subscribeToCollection(database, collection);
} else if (sub.type === 's3') {
const parts = sub.key.split('/');
const bucket = parts[0];
const prefix = parts.slice(1).join('/') || undefined;
await this.subscribeToBucket(bucket, prefix);
} else if (sub.type === 'activity') {
await this.subscribeToActivity();
}
} catch (error) {
console.error(`[ChangeStream] Failed to restore subscription ${sub.key}:`, error);
}
}
}
// ===========================================
// MongoDB Subscriptions
// ===========================================
/**
* Subscribe to changes in a MongoDB collection
*/
public async subscribeToCollection(database: string, collection: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
const key = `${database}/${collection}`;
// Check if already subscribed
if (this.subscriptions.has(`mongo:${key}`)) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('subscribeMongo');
const response = await request.fire({ database, collection });
if (response.success) {
this.subscriptions.set(`mongo:${key}`, {
type: 'mongo',
key,
subscriptionId: response.subscriptionId,
});
console.log(`[ChangeStream] Subscribed to MongoDB ${key}`);
return true;
}
return false;
} catch (error) {
console.error(`[ChangeStream] Failed to subscribe to MongoDB ${key}:`, error);
return false;
}
}
/**
* Unsubscribe from changes in a MongoDB collection
*/
public async unsubscribeFromCollection(database: string, collection: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
return false;
}
const key = `${database}/${collection}`;
const subKey = `mongo:${key}`;
if (!this.subscriptions.has(subKey)) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('unsubscribeMongo');
const response = await request.fire({ database, collection });
if (response.success) {
this.subscriptions.delete(subKey);
console.log(`[ChangeStream] Unsubscribed from MongoDB ${key}`);
}
return response.success;
} catch (error) {
console.error(`[ChangeStream] Failed to unsubscribe from MongoDB ${key}:`, error);
return false;
}
}
/**
* Check if subscribed to a MongoDB collection
*/
public isSubscribedToCollection(database: string, collection: string): boolean {
return this.subscriptions.has(`mongo:${database}/${collection}`);
}
// ===========================================
// S3 Subscriptions
// ===========================================
/**
* Subscribe to changes in an S3 bucket/prefix
*/
public async subscribeToBucket(bucket: string, prefix?: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
const key = prefix ? `${bucket}/${prefix}` : bucket;
// Check if already subscribed
if (this.subscriptions.has(`s3:${key}`)) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('subscribeS3');
const response = await request.fire({ bucket, prefix });
if (response.success) {
this.subscriptions.set(`s3:${key}`, {
type: 's3',
key,
subscriptionId: response.subscriptionId,
});
console.log(`[ChangeStream] Subscribed to S3 ${key}`);
return true;
}
return false;
} catch (error) {
console.error(`[ChangeStream] Failed to subscribe to S3 ${key}:`, error);
return false;
}
}
/**
* Unsubscribe from changes in an S3 bucket/prefix
*/
public async unsubscribeFromBucket(bucket: string, prefix?: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
return false;
}
const key = prefix ? `${bucket}/${prefix}` : bucket;
const subKey = `s3:${key}`;
if (!this.subscriptions.has(subKey)) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('unsubscribeS3');
const response = await request.fire({ bucket, prefix });
if (response.success) {
this.subscriptions.delete(subKey);
console.log(`[ChangeStream] Unsubscribed from S3 ${key}`);
}
return response.success;
} catch (error) {
console.error(`[ChangeStream] Failed to unsubscribe from S3 ${key}:`, error);
return false;
}
}
/**
* Check if subscribed to an S3 bucket/prefix
*/
public isSubscribedToBucket(bucket: string, prefix?: string): boolean {
const key = prefix ? `${bucket}/${prefix}` : bucket;
return this.subscriptions.has(`s3:${key}`);
}
// ===========================================
// Activity Stream Subscriptions
// ===========================================
/**
* Subscribe to the activity stream
*/
public async subscribeToActivity(): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
// Check if already subscribed
if (this.subscriptions.has('activity:activity')) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('subscribeActivity');
const response = await request.fire({});
if (response.success) {
this.subscriptions.set('activity:activity', {
type: 'activity',
key: 'activity',
subscriptionId: response.subscriptionId,
});
console.log('[ChangeStream] Subscribed to activity stream');
return true;
}
return false;
} catch (error) {
console.error('[ChangeStream] Failed to subscribe to activity stream:', error);
return false;
}
}
/**
* Unsubscribe from the activity stream
*/
public async unsubscribeFromActivity(): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
return false;
}
if (!this.subscriptions.has('activity:activity')) {
return true;
}
try {
const request = this.typedSocket.createTypedRequest<any>('unsubscribeActivity');
const response = await request.fire({});
if (response.success) {
this.subscriptions.delete('activity:activity');
console.log('[ChangeStream] Unsubscribed from activity stream');
}
return response.success;
} catch (error) {
console.error('[ChangeStream] Failed to unsubscribe from activity stream:', error);
return false;
}
}
/**
* Get recent activity events
*/
public async getRecentActivity(limit: number = 100): Promise<IActivityEvent[]> {
if (!this.typedSocket || !this.isConnected) {
return [];
}
try {
const request = this.typedSocket.createTypedRequest<any>('getRecentActivity');
const response = await request.fire({ limit });
return response.events || [];
} catch (error) {
console.error('[ChangeStream] Failed to get recent activity:', error);
return [];
}
}
/**
* Check if subscribed to activity stream
*/
public isSubscribedToActivity(): boolean {
return this.subscriptions.has('activity:activity');
}
// ===========================================
// Observables for UI Components
// ===========================================
/**
* Get MongoDB changes as an Observable
*/
public getMongoChanges(): plugins.smartrx.rxjs.Observable<IMongoChangeEvent> {
return this.mongoChanges$.asObservable();
}
/**
* Get S3 changes as an Observable
*/
public getS3Changes(): plugins.smartrx.rxjs.Observable<IS3ChangeEvent> {
return this.s3Changes$.asObservable();
}
/**
* Get activity events as an Observable
*/
public getActivityStream(): plugins.smartrx.rxjs.Observable<IActivityEvent> {
return this.activityEvents$.asObservable();
}
/**
* Get filtered MongoDB changes for a specific collection
*/
public getCollectionChanges(database: string, collection: string): plugins.smartrx.rxjs.Observable<IMongoChangeEvent> {
return this.mongoChanges$.pipe(
plugins.smartrx.rxjs.ops.filter(
(event) => event.database === database && event.collection === collection
)
);
}
/**
* Get filtered S3 changes for a specific bucket/prefix
*/
public getBucketChanges(bucket: string, prefix?: string): plugins.smartrx.rxjs.Observable<IS3ChangeEvent> {
return this.s3Changes$.pipe(
plugins.smartrx.rxjs.ops.filter((event) => {
if (event.bucket !== bucket) return false;
if (prefix && !event.key.startsWith(prefix)) return false;
return true;
})
);
}
}

View File

@@ -1,4 +1,8 @@
export * from './api.service.js'; export * from './api.service.js';
export * from './changestream.service.js';
import { ApiService } from './api.service.js'; import { ApiService } from './api.service.js';
import { ChangeStreamService } from './changestream.service.js';
export const apiService = new ApiService(); export const apiService = new ApiService();
export const changeStreamService = new ChangeStreamService();