8 Commits

31 changed files with 4487 additions and 245 deletions

View File

@@ -1,5 +1,46 @@
# 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)
add S3 create file/folder dialogs and in-place text editor; export mongodb plugin
- Add mongodb dependency and export mongodb in ts/plugins.ts so ObjectId can be reused from plugins.
- Update handlers.mongodb to use plugins.mongodb.ObjectId instead of requiring mongodb directly.
- UI: Add create-file and create-folder dialogs and context-menu entries in tsview-app, tsview-s3-columns, and tsview-s3-keys to create objects (folders use a .keep object).
- Implement client-side helpers to determine content type/default content and call apiService.putObject with base64 content when creating files/folders.
- S3 preview: embed dees-input-code editor for text files with language detection, unsaved-changes indicator, Save/Discard flows, and saving via apiService.putObject.
- Various styling and UX improvements for dialogs, buttons, and editor states.
## 2026-01-25 - 1.2.0 - feat(s3,web-ui)
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views
- Add server-side TypedHandlers: deletePrefix and getObjectUrl (ts/api/handlers.s3.ts)
- Add request/response interfaces IReq_DeletePrefix and IReq_GetObjectUrl (ts/interfaces/index.ts)
- Add client API methods deletePrefix and getObjectUrl (ts_web/services/api.service.ts)
- Introduce context menu actions (DeesContextmenu) across UI: bucket/database/collection/document/folder/file actions including open, copy path, delete, download and duplicate (ts_web/elements/tsview-app.ts, tsview-mongo-collections.ts, tsview-mongo-documents.ts, tsview-s3-columns.ts, tsview-s3-keys.ts)
- Switch from inline delete buttons to contextual menus for safer UX; implement downloads via data URLs returned by getObjectUrl and deletion of S3 prefixes (folders)
## 2026-01-25 - 1.1.3 - fix(package)
update package metadata

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tsview",
"version": "1.1.3",
"version": "1.5.0",
"private": false,
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
"main": "dist_ts/index.js",
@@ -19,11 +19,13 @@
"tsview": "cli.js"
},
"devDependencies": {
"@api.global/typedsocket": "^4.1.0",
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.6",
"@git.zone/tstest": "^3.1.7",
"@git.zone/tswatch": "3.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@types/node": "^25.0.10"
},
"dependencies": {
@@ -31,11 +33,11 @@
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.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",
"@push.rocks/early": "^4.0.4",
"@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/smartdata": "^7.0.15",
"@push.rocks/smartfile": "^13.1.2",
@@ -44,7 +46,8 @@
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartopen": "^2.0.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3"
"@push.rocks/smartpromise": "^4.2.3",
"mongodb": "^7.0.0"
},
"files": [
"ts/**/*",

58
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^3.975.0
version: 3.975.0
'@design.estate/dees-catalog':
specifier: ^3.37.0
version: 3.37.0(@tiptap/pm@2.27.2)
specifier: ^3.37.1
version: 3.37.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.1.5
version: 2.1.5
@@ -33,8 +33,8 @@ importers:
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartbucket':
specifier: ^4.3.1
version: 4.3.1
specifier: ^4.4.1
version: 4.4.1
'@push.rocks/smartcli':
specifier: ^4.0.20
version: 4.0.20
@@ -62,7 +62,13 @@ importers:
'@push.rocks/smartpromise':
specifier: ^4.2.3
version: 4.2.3
mongodb:
specifier: ^7.0.0
version: 7.0.0(socks@2.8.7)
devDependencies:
'@api.global/typedsocket':
specifier: ^4.1.0
version: 4.1.0(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbuild':
specifier: ^4.1.2
version: 4.1.2
@@ -73,11 +79,14 @@ importers:
specifier: ^2.0.1
version: 2.0.1
'@git.zone/tstest':
specifier: ^3.1.6
version: 3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
specifier: ^3.1.7
version: 3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
'@git.zone/tswatch':
specifier: 3.0.1
version: 3.0.1(@tiptap/pm@2.27.2)
'@push.rocks/smartrx':
specifier: ^3.0.10
version: 3.0.10
'@types/node':
specifier: ^25.0.10
version: 25.0.10
@@ -316,11 +325,14 @@ packages:
'@cloudflare/workers-types@4.20260123.0':
resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==}
'@cloudflare/workers-types@4.20260124.0':
resolution: {integrity: sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA==}
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.37.0':
resolution: {integrity: sha512-c6q+yK2FwMsMK72GykUhZnvKUgTzjFO9vdbn6OBxas2/eY/6Wi6BC5i9YONN0UYcW8yqjHIDjN9nP7yE1Ai4PA==}
'@design.estate/dees-catalog@3.37.1':
resolution: {integrity: sha512-NCgzzCG3NJVF7C7aa1nExCMhB+7nA6glFgZpsff32CpvdtbAuBQiuOngU0suVw65uK7Y0a2r/y2CEPGNNmj3TA==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -535,8 +547,8 @@ packages:
resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==}
hasBin: true
'@git.zone/tstest@3.1.6':
resolution: {integrity: sha512-xRGc6wO4rJ6mohPCMIBDRH+oNjiIvX6Jeo8v/Y5o5VyKSHFmqol7FCKSBrojMcqgBpESnLHFPJAAOmT9W3JV8Q==}
'@git.zone/tstest@3.1.7':
resolution: {integrity: sha512-YCDA+65LJhoY3WJxrNduKlpGf37aq4bFe+fdRqE0dZ2W1f7j3sUunBaBzckShSHKRjkMdPZKr0W0sXFXUK/PcA==}
hasBin: true
'@git.zone/tswatch@3.0.1':
@@ -820,8 +832,8 @@ packages:
'@push.rocks/smartbucket@3.3.10':
resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==}
'@push.rocks/smartbucket@4.3.1':
resolution: {integrity: sha512-fMA8w98/E+usaaLkLm6wDj1XSpR0shTtG8AxTdwWIlH1YemQj/aCf4wReezDxUFVoUpC3HMzzV2RTFtQvHndeQ==}
'@push.rocks/smartbucket@4.4.1':
resolution: {integrity: sha512-68GFLgJKW+LXvuN+yuV8O/FozGMecraoT+PkI5whdRPFe7N3u2iYIHWAUjvQvVU4ygpdJv0kih2JDf5k3PYycw==}
'@push.rocks/smartbuffer@3.0.5':
resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==}
@@ -2802,8 +2814,8 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide@0.562.0:
resolution: {integrity: sha512-k1Fb8ZMnRQovWRlea7Jr0b9UKA29IM7/cu79+mJrhVohvA2YC/Ti3Sk+G+h/SIu3IlrKT4RAbWMHUBBQd1O6XA==}
lucide@0.563.0:
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -4005,7 +4017,7 @@ snapshots:
'@api.global/typedrequest': 3.2.5
'@api.global/typedrequest-interfaces': 3.0.19
'@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
'@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.2.0
@@ -4054,7 +4066,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
'@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
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
@@ -4676,11 +4688,13 @@ snapshots:
'@cloudflare/workers-types@4.20260123.0': {}
'@cloudflare/workers-types@4.20260124.0': {}
'@configvault.io/interfaces@1.0.17':
dependencies:
'@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:
'@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-element': 2.1.5
@@ -4703,7 +4717,7 @@ snapshots:
apexcharts: 5.3.6
highlight.js: 11.11.1
ibantools: 4.5.1
lucide: 0.562.0
lucide: 0.563.0
monaco-editor: 0.55.1
pdfjs-dist: 4.10.38
xterm: 5.3.0
@@ -4959,7 +4973,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.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:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbundle': 2.8.3
@@ -5519,7 +5533,7 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@push.rocks/smartbucket@4.3.1':
'@push.rocks/smartbucket@4.4.1':
dependencies:
'@aws-sdk/client-s3': 3.975.0
'@push.rocks/smartmime': 2.0.4
@@ -5975,7 +5989,7 @@ snapshots:
'@push.rocks/smarts3@3.0.3':
dependencies:
'@push.rocks/smartbucket': 4.3.1
'@push.rocks/smartbucket': 4.4.1
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartxml': 2.0.0
@@ -8103,7 +8117,7 @@ snapshots:
lru-cache@7.18.3: {}
lucide@0.562.0: {}
lucide@0.563.0: {}
make-dir@3.1.0:
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 = {
name: '@git.zone/tsview',
version: '1.1.3',
version: '1.5.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
}

View File

@@ -21,15 +21,9 @@ export async function registerMongoHandlers(
// Helper to create ObjectId filter
const createIdFilter = (documentId: string) => {
// Try to treat as ObjectId string - MongoDB driver will handle conversion
try {
// Import ObjectId from the mongodb package that smartdata uses
const { ObjectId } = require('mongodb');
if (ObjectId.isValid(documentId)) {
return { _id: new ObjectId(documentId) };
}
} catch {
// Fall through to string filter
const { ObjectId } = plugins.mongodb;
if (ObjectId.isValid(documentId)) {
return { _id: new ObjectId(documentId) };
}
return { _id: documentId };
};
@@ -59,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
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>(

View File

@@ -292,6 +292,7 @@ export async function registerS3Handlers(
await bucket.fastPut({
path: reqData.key,
contents: content,
overwrite: true,
});
return { success: true };
@@ -354,6 +355,7 @@ export async function registerS3Handlers(
await destBucket.fastPut({
path: reqData.destKey,
contents: content,
overwrite: true,
});
return { success: true };
@@ -364,4 +366,103 @@ export async function registerS3Handlers(
}
)
);
// Delete prefix (folder and all contents)
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeletePrefix>(
'deletePrefix',
async (reqData) => {
const smartbucket = await tsview.getSmartBucket();
if (!smartbucket) {
return { success: false };
}
try {
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
if (!bucket) {
return { success: false };
}
const baseDir = await bucket.getBaseDirectory();
let targetDir = baseDir;
// Navigate to the prefix directory
const prefix = reqData.prefix.replace(/\/$/, '');
const prefixParts = prefix.split('/').filter(Boolean);
for (const part of prefixParts) {
const subDir = await targetDir.getSubDirectoryByName(part, { getEmptyDirectory: true });
if (subDir) {
targetDir = subDir;
} else {
return { success: false };
}
}
// Delete the directory and all its contents
await targetDir.delete({ mode: 'permanent' });
return { success: true };
} catch (err) {
console.error('Error deleting prefix:', err);
return { success: false };
}
}
)
);
// Get object URL (for downloads)
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObjectUrl>(
'getObjectUrl',
async (reqData) => {
const smartbucket = await tsview.getSmartBucket();
if (!smartbucket) {
throw new Error('S3 not configured');
}
try {
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
if (!bucket) {
throw new Error(`Bucket ${reqData.bucketName} not found`);
}
// Get the content and create a data URL
const content = await bucket.fastGet({ path: reqData.key });
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
const contentTypeMap: Record<string, string> = {
'json': 'application/json',
'txt': 'text/plain',
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'ts': 'text/plain',
'tsx': 'text/plain',
'jsx': 'text/plain',
'md': 'text/markdown',
'csv': 'text/csv',
'yaml': 'text/yaml',
'yml': 'text/yaml',
'log': 'text/plain',
'sh': 'text/plain',
'env': 'text/plain',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'pdf': 'application/pdf',
'xml': 'application/xml',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
const base64 = content.toString('base64');
const url = `data:${contentType};base64,${base64}`;
return { url };
} catch (err) {
console.error('Error getting object URL:', err);
throw err;
}
}
)
);
}

File diff suppressed because one or more lines are too long

View File

@@ -199,6 +199,34 @@ export interface IReq_CopyObject extends plugins.typedrequestInterfaces.implemen
};
}
export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeletePrefix
> {
method: 'deletePrefix';
request: {
bucketName: string;
prefix: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetObjectUrl
> {
method: 'getObjectUrl';
request: {
bucketName: string;
key: string;
};
response: {
url: string;
};
}
// ===========================================
// TypedRequest interfaces for MongoDB API
// ===========================================
@@ -483,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 smartpath from '@push.rocks/smartpath';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
export {
early,
@@ -31,12 +32,17 @@ export {
smartopen,
smartpath,
smartpromise,
smartrx,
};
// AWS S3 SDK for direct S3 operations
import * as s3 from '@aws-sdk/client-s3';
export { s3 };
// MongoDB driver for ObjectId handling
import * as mongodb from 'mongodb';
export { mongodb };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';

View File

@@ -1,7 +1,9 @@
import * as plugins from '../plugins.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 { registerMongoHandlers } from '../api/handlers.mongodb.js';
import { ChangeStreamManager } from '../streaming/index.js';
import { files as bundledUiFiles } from '../bundled_ui.js';
/**
@@ -11,6 +13,7 @@ export class ViewServer {
private tsview: TsView;
private port: number;
private typedServer: plugins.typedserver.TypedServer | null = null;
private changeStreamManager: ChangeStreamManager | null = null;
public typedrouter: plugins.typedrequest.TypedRouter;
constructor(tsview: TsView, port: number) {
@@ -41,14 +44,179 @@ export class ViewServer {
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
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
*/
public async stop(): Promise<void> {
// Stop change stream manager first
if (this.changeStreamManager) {
await this.changeStreamManager.stop();
this.changeStreamManager = null;
}
if (this.typedServer) {
await this.typedServer.stop();
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 = {
name: '@git.zone/tsview',
version: '1.1.3',
version: '1.5.0',
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-document.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,10 +1,11 @@
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';
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
type TViewMode = 's3' | 'mongo' | 'settings';
type TViewMode = 's3' | 'mongo' | 'activity' | 'settings';
@customElement('tsview-app')
export class TsviewApp extends DeesElement {
@@ -44,6 +45,27 @@ export class TsviewApp extends DeesElement {
@state()
private accessor newDatabaseName: string = '';
@state()
private accessor showSystemDatabases: boolean = false;
@state()
private accessor showS3CreateDialog: boolean = false;
@state()
private accessor s3CreateDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor s3CreateDialogBucket: string = '';
@state()
private accessor s3CreateDialogName: string = '';
@state()
private accessor sidebarWidth: number = 240;
@state()
private accessor isResizingSidebar: boolean = false;
public static styles = [
cssManager.defaultStyles,
themeStyles,
@@ -117,10 +139,22 @@ export class TsviewApp extends DeesElement {
.app-main {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-columns: var(--sidebar-width, 240px) 4px 1fr;
overflow: hidden;
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.sidebar {
background: #1e1e1e;
border-right: 1px solid #333;
@@ -296,6 +330,20 @@ export class TsviewApp extends DeesElement {
border-color: #e0e0e0;
}
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
margin-top: -8px;
}
.dialog-actions {
display: flex;
gap: 12px;
@@ -359,36 +407,32 @@ export class TsviewApp extends DeesElement {
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.sidebar-item:hover .delete-btn,
.db-group-header:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`,
];
private readonly SYSTEM_DATABASES = ['admin', 'config', 'local'];
private get visibleDatabases() {
if (this.showSystemDatabases) {
return this.databases;
}
return this.databases.filter(db => !this.SYSTEM_DATABASES.includes(db.name));
}
async connectedCallback() {
super.connectedCallback();
await this.loadData();
// 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() {
@@ -420,8 +464,15 @@ export class TsviewApp extends DeesElement {
}
private selectDatabase(db: string) {
this.selectedDatabase = db;
this.selectedCollection = '';
if (this.selectedDatabase === db) {
// Collapse - clicking the same database again
// Keep the collection selection intact
this.selectedDatabase = '';
} else {
// Switch to different database - clear collection
this.selectedDatabase = db;
this.selectedCollection = '';
}
}
private selectCollection(collection: string) {
@@ -495,6 +546,175 @@ export class TsviewApp extends DeesElement {
}
}
private handleBucketContextMenu(event: MouseEvent, bucket: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View Contents',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectBucket(bucket);
},
},
{ divider: true },
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openS3CreateDialog(bucket, 'folder'),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openS3CreateDialog(bucket, 'file'),
},
{ divider: true },
{
name: 'Delete Bucket',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) {
const success = await apiService.deleteBucket(bucket);
if (success) {
this.buckets = this.buckets.filter(b => b !== bucket);
if (this.selectedBucket === bucket) {
this.selectedBucket = this.buckets[0] || '';
}
}
}
},
},
]);
}
private openS3CreateDialog(bucket: string, type: 'folder' | 'file') {
this.s3CreateDialogBucket = bucket;
this.s3CreateDialogType = type;
this.s3CreateDialogName = '';
this.showS3CreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleS3Create() {
if (!this.s3CreateDialogName.trim()) return;
const name = this.s3CreateDialogName.trim();
let path: string;
if (this.s3CreateDialogType === 'folder') {
path = name + '/.keep';
} else {
path = name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.s3CreateDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.s3CreateDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.s3CreateDialogBucket,
path,
btoa(content),
contentType
);
if (success) {
this.showS3CreateDialog = false;
// Select the bucket to show the new content
this.selectedBucket = this.s3CreateDialogBucket;
// Trigger a refresh by dispatching an event
this.requestUpdate();
}
}
private handleDatabaseContextMenu(event: MouseEvent, dbName: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Collection',
iconName: 'lucide:folderPlus',
action: async () => {
this.selectedDatabase = dbName;
this.showCreateCollectionDialog = true;
},
},
{ divider: true },
{
name: 'Delete Database',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) {
const success = await apiService.dropDatabase(dbName);
if (success) {
this.databases = this.databases.filter(d => d.name !== dbName);
if (this.selectedDatabase === dbName) {
this.selectedDatabase = this.databases[0]?.name || '';
this.selectedCollection = '';
}
}
}
},
},
]);
}
private handleSidebarContextMenu(event: MouseEvent) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: this.showSystemDatabases ? 'Hide System Databases' : 'Show System Databases',
iconName: this.showSystemDatabases ? 'lucide:eyeOff' : 'lucide:eye',
action: async () => {
this.showSystemDatabases = !this.showSystemDatabases;
},
},
]);
}
private startSidebarResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingSidebar = true;
document.addEventListener('mousemove', this.handleSidebarResize);
document.addEventListener('mouseup', this.endSidebarResize);
};
private handleSidebarResize = (e: MouseEvent) => {
if (!this.isResizingSidebar) return;
const newWidth = Math.min(Math.max(e.clientX, 150), 500);
this.sidebarWidth = newWidth;
};
private endSidebarResize = () => {
this.isResizingSidebar = false;
document.removeEventListener('mousemove', this.handleSidebarResize);
document.removeEventListener('mouseup', this.endSidebarResize);
};
render() {
return html`
<div class="app-container">
@@ -520,6 +740,12 @@ export class TsviewApp extends DeesElement {
>
MongoDB
</button>
<button
class="nav-tab ${this.viewMode === 'activity' ? 'active' : ''}"
@click=${() => this.setViewMode('activity')}
>
Activity
</button>
<button
class="nav-tab ${this.viewMode === 'settings' ? 'active' : ''}"
@click=${() => this.setViewMode('settings')}
@@ -529,14 +755,19 @@ export class TsviewApp extends DeesElement {
</nav>
</header>
<main class="app-main">
<main class="app-main" style="--sidebar-width: ${this.sidebarWidth}px">
${this.renderSidebar()}
<div
class="resize-divider ${this.isResizingSidebar ? 'active' : ''}"
@mousedown=${this.startSidebarResize}
></div>
${this.renderContent()}
</main>
</div>
${this.renderCreateBucketDialog()}
${this.renderCreateCollectionDialog()}
${this.renderCreateDatabaseDialog()}
${this.renderS3CreateDialog()}
`;
}
@@ -633,6 +864,48 @@ export class TsviewApp extends DeesElement {
`;
}
private renderS3CreateDialog() {
if (!this.showS3CreateDialog) return '';
const isFolder = this.s3CreateDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showS3CreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.s3CreateDialogBucket}/
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.s3CreateDialogName}
@input=${(e: InputEvent) => this.s3CreateDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleS3Create()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showS3CreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.s3CreateDialogName.trim()}
@click=${() => this.handleS3Create()}
>
Create
</button>
</div>
</div>
</div>
`;
}
private renderSidebar() {
if (this.viewMode === 's3') {
return html`
@@ -653,14 +926,9 @@ export class TsviewApp extends DeesElement {
<div
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
@click=${() => this.selectBucket(bucket)}
@contextmenu=${(e: MouseEvent) => this.handleBucketContextMenu(e, bucket)}
>
<span class="sidebar-item-name">${bucket}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteBucket(bucket, e)} title="Delete bucket">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`
)}
@@ -671,7 +939,7 @@ export class TsviewApp extends DeesElement {
if (this.viewMode === 'mongo') {
return html`
<aside class="sidebar">
<aside class="sidebar" @contextmenu=${(e: MouseEvent) => this.handleSidebarContextMenu(e)}>
<div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -680,19 +948,23 @@ export class TsviewApp extends DeesElement {
</svg>
New Database
</button>
${this.selectedDatabase ? html`
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Collection
</button>
` : ''}
<div class="sidebar-list">
${this.databases.length === 0
${this.visibleDatabases.length === 0
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
: this.databases.map((db) => this.renderDatabaseGroup(db))}
: this.visibleDatabases.map((db) => this.renderDatabaseGroup(db))}
</div>
</aside>
`;
}
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>
</aside>
`;
@@ -715,6 +987,7 @@ export class TsviewApp extends DeesElement {
<div
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
@click=${() => this.selectDatabase(db.name)}
@contextmenu=${(e: MouseEvent) => this.handleDatabaseContextMenu(e, db.name)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
@@ -722,12 +995,6 @@ export class TsviewApp extends DeesElement {
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span style="flex: 1;">${db.name}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteDatabase(db.name, e)} title="Delete database">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
</div>
@@ -790,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`
<div class="content-area">
<tsview-mongo-browser
@@ -800,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`
<div class="content-area">
<h2>Settings</h2>
@@ -807,4 +1096,21 @@ export class TsviewApp extends DeesElement {
</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 { 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 { themeStyles } from '../styles/index.js';
@@ -24,6 +24,20 @@ export class TsviewMongoBrowser extends DeesElement {
@state()
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 = [
cssManager.defaultStyles,
themeStyles,
@@ -102,11 +116,23 @@ export class TsviewMongoBrowser extends DeesElement {
.content {
flex: 1;
display: grid;
grid-template-columns: 1fr 400px;
gap: 16px;
grid-template-columns: 1fr 4px var(--editor-width, 400px);
gap: 0;
overflow: hidden;
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.main-panel {
overflow: auto;
background: rgba(0, 0, 0, 0.2);
@@ -117,6 +143,7 @@ export class TsviewMongoBrowser extends DeesElement {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
margin-left: 12px;
}
@media (max-width: 1200px) {
@@ -124,22 +151,119 @@ export class TsviewMongoBrowser extends DeesElement {
grid-template-columns: 1fr;
}
.detail-panel {
.detail-panel,
.resize-divider {
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() {
super.connectedCallback();
await this.loadStats();
this.subscribeToChanges();
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeFromChanges();
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
this.loadStats();
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;
}
private startEditorResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingEditor = true;
document.addEventListener('mousemove', this.handleEditorResize);
document.addEventListener('mouseup', this.endEditorResize);
};
private handleEditorResize = (e: MouseEvent) => {
if (!this.isResizingEditor) return;
const contentEl = this.shadowRoot?.querySelector('.content');
if (!contentEl) return;
const containerRect = contentEl.getBoundingClientRect();
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 300), 700);
this.editorWidth = newWidth;
};
private endEditorResize = () => {
this.isResizingEditor = false;
document.removeEventListener('mousemove', this.handleEditorResize);
document.removeEventListener('mouseup', this.endEditorResize);
};
render() {
return html`
<div class="browser-container">
@@ -177,6 +323,17 @@ export class TsviewMongoBrowser extends DeesElement {
</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 class="tabs">
@@ -201,7 +358,7 @@ export class TsviewMongoBrowser extends DeesElement {
</div>
</div>
<div class="content">
<div class="content" style="--editor-width: ${this.editorWidth}px">
<div class="main-panel">
${this.activeTab === 'documents'
? html`
@@ -225,6 +382,11 @@ export class TsviewMongoBrowser extends DeesElement {
`}
</div>
<div
class="resize-divider ${this.isResizingEditor ? 'active' : ''}"
@mousedown=${this.startEditorResize}
></div>
<div class="detail-panel">
<tsview-mongo-document
.databaseName=${this.databaseName}

View File

@@ -4,6 +4,7 @@ import { formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
declare global {
interface HTMLElementEventMap {
@@ -90,27 +91,31 @@ export class TsviewMongoCollections extends DeesElement {
font-style: italic;
}
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
.overview-item {
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
gap: 6px;
transition: background 0.1s;
color: #a5d6a7;
margin-bottom: 4px;
}
.collection-item:hover .delete-btn {
opacity: 1;
.overview-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
.overview-item.selected {
background: rgba(165, 214, 167, 0.15);
color: #a5d6a7;
}
.overview-item svg {
width: 14px;
height: 14px;
}
`,
];
@@ -149,8 +154,7 @@ export class TsviewMongoCollections extends DeesElement {
);
}
private async deleteCollection(name: string, e: Event) {
e.stopPropagation();
private async deleteCollection(name: string) {
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
const success = await apiService.dropCollection(this.databaseName, name);
@@ -166,47 +170,81 @@ export class TsviewMongoCollections extends DeesElement {
}
}
private handleCollectionContextMenu(event: MouseEvent, collection: IMongoCollection) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View Documents',
iconName: 'lucide:fileText',
action: async () => {
this.selectCollection(collection.name);
},
},
{ divider: true },
{
name: 'Delete Collection',
iconName: 'lucide:trash2',
action: async () => {
await this.deleteCollection(collection.name);
},
},
]);
}
public async refresh() {
await this.loadCollections();
}
private selectOverview() {
this.dispatchEvent(
new CustomEvent('collection-selected', {
detail: '__overview__',
bubbles: true,
composed: true,
})
);
}
render() {
if (this.loading) {
return html`<div class="loading-state">Loading collections...</div>`;
}
if (this.collections.length === 0) {
return html`<div class="empty-state">No collections</div>`;
}
return html`
<div class="collections-list">
${this.collections.map(
(coll) => html`
<div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
@click=${() => this.selectCollection(coll.name)}
>
<span class="collection-name">
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
${coll.name}
</span>
<span style="display: flex; align-items: center; gap: 4px;">
${coll.count !== undefined
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''}
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</span>
</div>
`
)}
<div
class="overview-item ${this.selectedCollection === '__overview__' ? 'selected' : ''}"
@click=${() => this.selectOverview()}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Overview
</div>
${this.collections.length === 0
? html`<div class="empty-state">No collections</div>`
: this.collections.map(
(coll) => html`
<div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
@click=${() => this.selectCollection(coll.name)}
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
>
<span class="collection-name">
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
${coll.name}
</span>
${coll.count !== undefined
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''}
</div>
`
)}
</div>
`;
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
@customElement('tsview-mongo-documents')
export class TsviewMongoDocuments extends DeesElement {
@@ -258,6 +259,13 @@ export class TsviewMongoDocuments extends DeesElement {
this.loading = false;
}
/**
* Public method to refresh documents (called by parent on change events)
*/
public async refresh() {
await this.loadDocuments();
}
private handleFilterInput(e: Event) {
this.filterText = (e.target as HTMLInputElement).value;
}
@@ -330,6 +338,74 @@ export class TsviewMongoDocuments extends DeesElement {
}
}
private handleDocumentContextMenu(event: MouseEvent, doc: Record<string, unknown>) {
event.preventDefault();
const docId = doc._id as string;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View/Edit',
iconName: 'lucide:edit',
action: async () => {
this.selectDocument(doc);
},
},
{
name: 'Copy as JSON',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(JSON.stringify(doc, null, 2));
},
},
{
name: 'Duplicate',
iconName: 'lucide:copyPlus',
action: async () => {
const { _id, ...docWithoutId } = doc;
const newDoc = { ...docWithoutId, createdAt: new Date().toISOString() };
try {
const insertedId = await apiService.insertDocument(
this.databaseName,
this.collectionName,
newDoc
);
await this.loadDocuments();
this.selectedId = insertedId;
this.dispatchEvent(
new CustomEvent('document-selected', {
detail: { documentId: insertedId },
bubbles: true,
composed: true,
})
);
} catch (err) {
console.error('Error duplicating document:', err);
}
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete document "${docId}"?`)) {
const result = await apiService.deleteDocument(
this.databaseName,
this.collectionName,
docId
);
if (result.success) {
await this.loadDocuments();
if (this.selectedId === docId) {
this.selectedId = '';
}
}
}
},
},
]);
}
render() {
const startRecord = (this.page - 1) * this.pageSize + 1;
const endRecord = Math.min(this.page * this.pageSize, this.total);
@@ -362,6 +438,7 @@ export class TsviewMongoDocuments extends DeesElement {
<div
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
@click=${() => this.selectDocument(doc)}
@contextmenu=${(e: MouseEvent) => this.handleDocumentContextMenu(e, doc)}
>
<div class="document-id">_id: ${doc._id}</div>
<div class="document-preview">${this.getDocumentPreview(doc)}</div>

View File

@@ -1,5 +1,5 @@
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';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -23,6 +23,20 @@ export class TsviewS3Browser extends DeesElement {
@state()
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 = [
cssManager.defaultStyles,
themeStyles,
@@ -104,12 +118,24 @@ export class TsviewS3Browser extends DeesElement {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
gap: 0;
overflow: hidden;
}
.content.has-preview {
grid-template-columns: 1fr 350px;
grid-template-columns: 1fr 4px var(--preview-width, 350px);
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.main-view {
@@ -122,6 +148,7 @@ export class TsviewS3Browser extends DeesElement {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
margin-left: 12px;
}
@media (max-width: 1024px) {
@@ -130,13 +157,66 @@ export class TsviewS3Browser extends DeesElement {
grid-template-columns: 1fr;
}
.preview-panel {
.preview-panel,
.resize-divider {
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) {
this.viewType = type;
}
@@ -165,9 +245,76 @@ export class TsviewS3Browser extends DeesElement {
// Clear selection when bucket changes
this.selectedKey = '';
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() {
const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean)
@@ -197,6 +344,17 @@ export class TsviewS3Browser extends DeesElement {
})}
</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">
<button
class="view-btn ${this.viewType === 'columns' ? 'active' : ''}"
@@ -213,7 +371,7 @@ export class TsviewS3Browser extends DeesElement {
</div>
</div>
<div class="content ${this.selectedKey ? 'has-preview' : ''}">
<div class="content ${this.selectedKey ? 'has-preview' : ''}" style="--preview-width: ${this.previewWidth}px">
<div class="main-view">
${this.viewType === 'columns'
? html`
@@ -238,6 +396,10 @@ export class TsviewS3Browser extends DeesElement {
${this.selectedKey
? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel">
<tsview-s3-preview
.bucketName=${this.bucketName}

View File

@@ -4,6 +4,7 @@ import { getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
interface IColumn {
prefix: string;
@@ -30,6 +31,18 @@ export class TsviewS3Columns extends DeesElement {
@state()
private accessor loading: boolean = false;
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null;
private readonly DEFAULT_COLUMN_WIDTH = 250;
private readonly MIN_COLUMN_WIDTH = 150;
@@ -169,6 +182,104 @@ export class TsviewS3Columns extends DeesElement {
text-align: center;
color: #666;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #1e1e1e;
border-radius: 12px;
padding: 24px;
min-width: 400px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.dialog-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dialog-btn-cancel {
background: transparent;
border: 1px solid #444;
color: #aaa;
}
.dialog-btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.dialog-btn-create {
background: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`,
];
@@ -318,6 +429,221 @@ export class TsviewS3Columns extends DeesElement {
return iconMap[ext] || 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
}
private handleFolderContextMenu(event: MouseEvent, columnIndex: number, prefix: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Open',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectFolder(columnIndex, prefix);
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(prefix);
},
},
{ divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{ divider: true },
{
name: 'Delete Folder',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, prefix);
if (success) {
await this.loadInitialColumn();
}
}
},
},
]);
}
private handleFileContextMenu(event: MouseEvent, columnIndex: number, key: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Preview',
iconName: 'lucide:eye',
action: async () => {
this.selectFile(columnIndex, key);
},
},
{
name: 'Download',
iconName: 'lucide:download',
action: async () => {
const url = await apiService.getObjectUrl(this.bucketName, key);
const link = document.createElement('a');
link.href = url;
link.download = getFileName(key);
link.click();
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete file "${getFileName(key)}"?`)) {
const success = await apiService.deleteObject(this.bucketName, key);
if (success) {
await this.loadInitialColumn();
}
}
},
},
]);
}
private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) {
// Only trigger if clicking on the container itself, not on items
if (event.target !== event.currentTarget) return;
event.preventDefault();
const prefix = this.columns[columnIndex].prefix;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
// Support deep paths: "a/b/c" creates nested folders
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.loadInitialColumn();
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() {
if (this.loading && this.columns.length === 0) {
return html`<div class="loading">Loading...</div>`;
@@ -327,6 +653,7 @@ export class TsviewS3Columns extends DeesElement {
<div class="columns-container">
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
</div>
${this.renderCreateDialog()}
`;
}
@@ -352,7 +679,7 @@ export class TsviewS3Columns extends DeesElement {
<div class="column-header" title=${column.prefix || this.bucketName}>
${headerName}
</div>
<div class="column-items">
<div class="column-items" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e, index)}>
${column.prefixes.length === 0 && column.objects.length === 0
? html`<div class="empty-state">Empty folder</div>`
: ''}
@@ -361,6 +688,7 @@ export class TsviewS3Columns extends DeesElement {
<div
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
@click=${() => this.selectFolder(index, prefix)}
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
>
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
<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" />
@@ -377,6 +705,7 @@ export class TsviewS3Columns extends DeesElement {
<div
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
@click=${() => this.selectFile(index, obj.key)}
@contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)}
>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${this.getFileIcon(obj.key)}" />

View File

@@ -4,6 +4,7 @@ import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
@customElement('tsview-s3-keys')
export class TsviewS3Keys extends DeesElement {
@@ -31,6 +32,18 @@ export class TsviewS3Keys extends DeesElement {
@state()
private accessor filterText: string = '';
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
public static styles = [
cssManager.defaultStyles,
themeStyles,
@@ -145,6 +158,104 @@ export class TsviewS3Keys extends DeesElement {
text-align: center;
color: #666;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #1e1e1e;
border-radius: 12px;
padding: 24px;
min-width: 400px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.dialog-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dialog-btn-cancel {
background: transparent;
border: 1px solid #444;
color: #aaa;
}
.dialog-btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.dialog-btn-create {
background: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`,
];
@@ -210,6 +321,218 @@ export class TsviewS3Keys extends DeesElement {
return [...folders, ...files];
}
private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) {
event.preventDefault();
if (isFolder) {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Open',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectKey(key, true);
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', key),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', key),
},
{ divider: true },
{
name: 'Delete Folder',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, key);
if (success) {
await this.loadObjects();
}
}
},
},
]);
} else {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Preview',
iconName: 'lucide:eye',
action: async () => {
this.selectKey(key, false);
},
},
{
name: 'Download',
iconName: 'lucide:download',
action: async () => {
const url = await apiService.getObjectUrl(this.bucketName, key);
const link = document.createElement('a');
link.href = url;
link.download = getFileName(key);
link.click();
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete file "${getFileName(key)}"?`)) {
const success = await apiService.deleteObject(this.bucketName, key);
if (success) {
await this.loadObjects();
}
}
},
},
]);
}
}
private handleEmptySpaceContextMenu(event: MouseEvent) {
// Only trigger if clicking on the container itself, not on items
if ((event.target as HTMLElement).closest('tr')) return;
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', this.currentPrefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', this.currentPrefix),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.loadObjects();
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() {
return html`
<div class="keys-container">
@@ -223,7 +546,7 @@ export class TsviewS3Keys extends DeesElement {
/>
</div>
<div class="keys-list">
<div class="keys-list" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e)}>
${this.loading
? html`<div class="empty-state">Loading...</div>`
: this.filteredItems.length === 0
@@ -242,6 +565,7 @@ export class TsviewS3Keys extends DeesElement {
<tr
class="${this.selectedKey === item.key ? 'selected' : ''}"
@click=${() => this.selectKey(item.key, item.isFolder)}
@contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)}
>
<td>
<div class="key-cell">
@@ -270,6 +594,7 @@ export class TsviewS3Keys extends DeesElement {
`}
</div>
</div>
${this.renderCreateDialog()}
`;
}
}

View File

@@ -16,9 +16,18 @@ export class TsviewS3Preview extends DeesElement {
@state()
private accessor loading: boolean = false;
@state()
private accessor saving: boolean = false;
@state()
private accessor content: string = '';
@state()
private accessor originalTextContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state()
private accessor contentType: string = '';
@@ -78,6 +87,15 @@ export class TsviewS3Preview extends DeesElement {
padding: 12px;
}
.preview-content.code-editor {
padding: 0;
overflow: hidden;
}
.preview-content.code-editor dees-input-code {
height: 100%;
}
.preview-image {
max-width: 100%;
max-height: 100%;
@@ -130,6 +148,51 @@ export class TsviewS3Preview extends DeesElement {
background: rgba(239, 68, 68, 0.3);
}
.action-btn.primary {
background: rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
color: #60a5fa;
}
.action-btn.primary:hover {
background: rgba(59, 130, 246, 0.4);
}
.action-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.05);
border-color: #555;
color: #aaa;
}
.action-btn.secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.unsaved-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 4px;
font-size: 12px;
color: #fbbf24;
}
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fbbf24;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -177,7 +240,9 @@ export class TsviewS3Preview extends DeesElement {
} else {
this.content = '';
this.contentType = '';
this.error = ''; // Clear error when no file selected
this.error = '';
this.originalTextContent = '';
this.hasChanges = false;
}
}
}
@@ -187,13 +252,24 @@ export class TsviewS3Preview extends DeesElement {
this.loading = true;
this.error = '';
this.hasChanges = false;
try {
const result = await apiService.getObject(this.bucketName, this.objectKey);
this.content = result.content;
this.contentType = result.contentType;
this.size = result.size;
this.lastModified = result.lastModified;
if (!result) {
this.error = 'Object not found';
this.loading = false;
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
if (this.isText()) {
this.originalTextContent = this.getTextContent();
}
} catch (err) {
console.error('Error loading object:', err);
this.error = 'Failed to load object';
@@ -270,6 +346,98 @@ export class TsviewS3Preview extends DeesElement {
}
}
private getLanguage(): string {
const ext = this.objectKey.split('.').pop()?.toLowerCase() || '';
const languageMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
json: 'json',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
sass: 'scss',
less: 'less',
md: 'markdown',
markdown: 'markdown',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
dockerfile: 'dockerfile',
txt: 'plaintext',
};
return languageMap[ext] || 'plaintext';
}
private handleContentChange(event: CustomEvent) {
const newValue = event.detail as string;
this.hasChanges = newValue !== this.originalTextContent;
}
private handleDiscard() {
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
if (codeEditor) {
codeEditor.value = this.originalTextContent;
}
this.hasChanges = false;
}
private async handleSave() {
if (!this.hasChanges || this.saving) return;
this.saving = true;
try {
// Get current content from the editor
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
const currentContent = codeEditor?.value ?? '';
// Encode the text content to base64
const encoder = new TextEncoder();
const bytes = encoder.encode(currentContent);
const base64Content = btoa(String.fromCharCode(...bytes));
const success = await apiService.putObject(
this.bucketName,
this.objectKey,
base64Content,
this.contentType
);
if (success) {
this.originalTextContent = currentContent;
this.hasChanges = false;
// Update the stored content as well
this.content = base64Content;
}
} catch (err) {
console.error('Error saving object:', err);
}
this.saving = false;
}
render() {
if (!this.objectKey) {
return html`
@@ -309,14 +477,27 @@ export class TsviewS3Preview extends DeesElement {
<span class="meta-item">${this.contentType}</span>
<span class="meta-item">${formatSize(this.size)}</span>
<span class="meta-item">${this.formatDate(this.lastModified)}</span>
${this.hasChanges ? html`
<span class="unsaved-indicator">
<span class="unsaved-dot"></span>
Unsaved changes
</span>
` : ''}
</div>
</div>
<div class="preview-content">
<div class="preview-content ${this.isText() ? 'code-editor' : ''}">
${this.isImage()
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
: this.isText()
? html`<pre class="preview-text">${this.getTextContent()}</pre>`
? html`
<dees-input-code
.value=${this.originalTextContent}
.language=${this.getLanguage()}
height="100%"
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
></dees-input-code>
`
: html`
<div class="binary-preview">
<p>Binary file preview not available</p>
@@ -326,8 +507,19 @@ export class TsviewS3Preview extends DeesElement {
</div>
<div class="preview-actions">
<button class="action-btn" @click=${this.handleDownload}>Download</button>
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
${this.hasChanges ? html`
<button class="action-btn secondary" @click=${this.handleDiscard}>Discard</button>
<button
class="action-btn primary"
@click=${this.handleSave}
?disabled=${this.saving}
>
${this.saving ? 'Saving...' : 'Save'}
</button>
` : html`
<button class="action-btn" @click=${this.handleDownload}>Download</button>
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
`}
</div>
</div>
`;

View File

@@ -18,4 +18,9 @@ export const DeesElement = deesElement.DeesElement;
// @api.global scope
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;
}
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
*/
@@ -128,6 +139,22 @@ export class ApiService {
return result.success;
}
async deletePrefix(bucketName: string, prefix: string): Promise<boolean> {
const result = await this.request<
{ bucketName: string; prefix: string },
{ success: boolean }
>('deletePrefix', { bucketName, prefix });
return result.success;
}
async getObjectUrl(bucketName: string, key: string): Promise<string> {
const result = await this.request<
{ bucketName: string; key: string },
{ url: string }
>('getObjectUrl', { bucketName, key });
return result.url;
}
async copyObject(
sourceBucket: string,
sourceKey: string,
@@ -328,4 +355,12 @@ export class ApiService {
}> {
return this.request('getServerStatus', {});
}
async getDatabaseStats(databaseName: string): Promise<IDatabaseStats | null> {
const result = await this.request<
{ databaseName: string },
{ stats: IDatabaseStats | null }
>('getDatabaseStats', { databaseName });
return result.stats;
}
}

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 './changestream.service.js';
import { ApiService } from './api.service.js';
import { ChangeStreamService } from './changestream.service.js';
export const apiService = new ApiService();
export const changeStreamService = new ChangeStreamService();