11 Commits

Author SHA1 Message Date
319ee2a7af v1.8.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 13m35s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 14:10:43 +00:00
8204f06a2a fix(cli): set executable permission on cli.js 2026-01-28 14:10:43 +00:00
be71629d34 v1.8.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 13m38s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 14:02:48 +00:00
8cc9a1850a feat(streaming): add global activity watchers, client-side buffering, and improved real-time streaming UX 2026-01-28 14:02:48 +00:00
ad8529cb0f v1.7.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 13m41s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-27 15:51:19 +00:00
7cef6f89d9 feat(s3): add drag-and-drop and context-menu file uploads, inline text editing in preview, and increase preview width 2026-01-27 15:51:19 +00:00
81d7ff0722 fix(build): update bundled UI after rebuild 2026-01-26 12:49:23 +00:00
856f13f2ad v1.6.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 14m47s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-26 12:48:50 +00:00
f7cd43933f fix(ci): add Gitea CI workflows, documentation updates, and packaging metadata tweaks 2026-01-26 12:48:50 +00:00
4269058ab5 v1.6.0 2026-01-25 22:04:07 +00:00
321e3e89a4 feat(readme): document real-time change streaming and expand README with features, architecture, and configuration updates 2026-01-25 22:04:07 +00:00
24 changed files with 1416 additions and 361 deletions

View File

@@ -0,0 +1,66 @@
name: Default (not tags)
on:
push:
tags-ignore:
- '**'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${-{gitea.repository_owner}-}:${-{secrets.GITEA_TOKEN}-}@{{module.githost}}/${-{gitea.repository}-}.git
NPMCI_TOKEN_NPM: ${-{secrets.NPMCI_TOKEN_NPM}-}
NPMCI_TOKEN_NPM2: ${-{secrets.NPMCI_TOKEN_NPM2}-}
NPMCI_GIT_GITHUBTOKEN: ${-{secrets.NPMCI_GIT_GITHUBTOKEN}-}
NPMCI_URL_CLOUDLY: ${-{secrets.NPMCI_URL_CLOUDLY}-}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${-{ env.IMAGE }-}
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
- name: Run npm prepare
run: npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${-{ always() }-}
needs: security
runs-on: ubuntu-latest
container:
image: ${-{ env.IMAGE }-}
steps:
- uses: actions/checkout@v3
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build

View File

@@ -0,0 +1,124 @@
name: Default (tags)
on:
push:
tags:
- '*'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${-{gitea.repository_owner}-}:${-{secrets.GITEA_TOKEN}-}@{{module.githost}}/${-{gitea.repository}-}.git
NPMCI_TOKEN_NPM: ${-{secrets.NPMCI_TOKEN_NPM}-}
NPMCI_TOKEN_NPM2: ${-{secrets.NPMCI_TOKEN_NPM2}-}
NPMCI_GIT_GITHUBTOKEN: ${-{secrets.NPMCI_GIT_GITHUBTOKEN}-}
NPMCI_URL_CLOUDLY: ${-{secrets.NPMCI_URL_CLOUDLY}-}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${-{ env.IMAGE }-}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${-{ always() }-}
needs: security
runs-on: ubuntu-latest
container:
image: ${-{ env.IMAGE }-}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${-{ env.IMAGE }-}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Release
run: |
npmci node install stable
npmci npm publish
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${-{ env.IMAGE }-}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Code quality
run: |
npmci command npm install -g typescript
npmci npm install
- name: Trigger
run: npmci trigger
- name: Build docs and upload artifacts
run: |
npmci node install stable
npmci npm install
pnpm install -g @git.zone/tsdoc
npmci command tsdoc
continue-on-error: true

2
.gitignore vendored
View File

@@ -20,4 +20,4 @@ dist_*/
.claude/
.serena/
#------# custom
#------# custom

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "npm test",
"name": "Run npm test",
"request": "launch",
"type": "node-terminal"
}
]
}

26
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"schema": {
"type": "object",
"properties": {
"npmci": {
"type": "object",
"description": "settings for npmci"
},
"gitzone": {
"type": "object",
"description": "settings for gitzone",
"properties": {
"projectType": {
"type": "string",
"enum": ["website", "element", "service", "npm", "wcc"]
}
}
}
}
}
}
]
}

View File

@@ -1,6 +1,56 @@
# Changelog
## 2026-01-28 - 1.8.1 - fix(cli)
set executable permission on cli.js
- metadata-only change: updated file mode for cli.js (executable bit set)
- no source code changes; ensures package binary is runnable
## 2026-01-28 - 1.8.0 - feat(streaming)
add global activity watchers, client-side buffering, and improved real-time streaming UX
- Introduce global MongoDB and S3 watchers in ChangeStreamManager to feed a deployment-level activity stream (start/stop automatically based on subscribers)
- ChangeStreamManager: activity buffering, globalWatchersActive flag, start/stop global watchers, and emitMongoActivityEvent API for handlers
- Mongo API handlers accept an optional ChangeStreamManager and emit activity events for DB/collection/document operations
- Server now initializes ChangeStreamManager earlier and passes it to registerMongoHandlers; request context uses localData.peer.id for WebSocket peer lookup
- Client: ChangeStreamService adds connection promise/ensureConnected, activity buffering across tabs, and more robust connect/subscribe flows (waits for connect in-flight)
- UI updates: activity stream shows relative times with live clock, buffers events from app-level subscription, auto-scroll behavior adjusted, and connection-based re-subscription
- Mongo/S3 browser components ensure RxJS listeners are attached before server-side subscribe and surface connection status
- S3 browser: add drag-and-drop folder upload support (webkitdirectory), recursive folder entry traversal, upload with preserved relative paths, and column refresh/refreshAll logic
- Minor API/behavioral changes use connection peer id via conn.peer?.id when resolving target connections
## 2026-01-27 - 1.7.0 - feat(s3)
add drag-and-drop and context-menu file uploads, inline text editing in preview, and increase preview width
- Add drag-and-drop uploading into columns and folders with visual drag-over / drag-target states and a delayed folder-hover target
- Add context-menu 'Upload Files' action and hidden file input to trigger multi-file uploads; show upload overlay with progress and refresh column after upload
- Implement upload handling using apiService.putObject and base64 file reads, with upload progress tracking and error logging
- Add inline text editing in the preview (Edit / Save / Discard flow) using dees-input-code for editing and dees-preview for read-only display
- Increase preview default width from 350px to 700px and raise max resize limit from 600px to 1000px
- Bump dependencies: @git.zone/tstest -> ^3.1.8, @design.estate/dees-catalog -> ^3.41.1, @design.estate/dees-element -> ^2.1.6
## 2026-01-26 - 1.6.1 - fix(ci)
add Gitea CI workflows, documentation updates, and packaging metadata tweaks
- Add .gitea workflow files for tag and non-tag pushes (security, test, release, metadata jobs)
- Add buildDocs script (tsdoc) and add bugs/homepage/pnpm.overrides entries to package.json
- Update README and readme.hints.md with real-time streaming docs, formatting/table fixes, and examples punctuation fixes
- Tidy npmextra.json formatting and array inline style changes
- Update changelog.md entries and correct trailing newline/formatting
- Minor .gitignore whitespace fix
## 2026-01-25 - 1.6.0 - feat(readme)
document real-time change streaming and expand README with features, architecture, and configuration updates
- Add Real-Time Change Streaming section: MongoDB change streams, S3 polling (ETag), activity stream, WebSocket subscriptions, and auto-reconnect behavior
- Expand S3 and MongoDB feature lists: in-place text editing, enhanced previews (code), show/hide system databases, live indicators, context menus
- Reintroduce project-level npmextra.json example and clarify environment variable names and priority order
- Add Architecture and How It Works sections with tree layout and streaming design details
- Minor wording, formatting, and installation clarifications (prefer pnpm examples, fix LICENSE filename case)
## 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.
@@ -11,6 +61,7 @@ add real-time streaming (MongoDB change streams & S3 bucket watchers) with WebSo
- 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()
@@ -23,6 +74,7 @@ add database overview panel, collection overview and resizable panels; show/hide
- 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.
@@ -33,6 +85,7 @@ add S3 create file/folder dialogs and in-place text editor; export mongodb plugi
- 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)
@@ -42,24 +95,28 @@ add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S
- 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
- metadata-only change; no source code changes
- current version 1.1.2 → recommended patch bump to 1.1.3
## 2026-01-25 - 1.1.2 - fix(package)
apply minor metadata-only change (one-line edit)
- Change affects 1 file with a +1 -1 (metadata-only) — no behavioral changes
- Recommended bump of patch version from 1.1.1 to 1.1.2
## 2026-01-25 - 1.1.1 - fix(tsview)
fix bad build commit - remove accidental include
- Removed an accidental include that caused a bad build and unintended files to be part of the commit
- Patch release recommended from 1.1.0 to 1.1.1
## 2026-01-25 - 1.1.0 - feat(tsview)
add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config
- Add MongoDB management handlers: createDatabase, dropDatabase, dropCollection (ts/api/handlers.mongodb.ts)
@@ -71,6 +128,7 @@ add database and S3 handlers, tswatch/watch scripts, web utilities, assets and r
- Add release/registry and project metadata in npmextra.json for publishing
## 2026-01-23 - 1.0.0 - initial release: column view UI, S3 integration, and API fixes
Initial public release introducing the new column-based UI with resizable columns and horizontal navigation, plus backend fixes for S3 bucket listing and API endpoint handling.
- feat: Add resizable columns and horizontal scrolling
@@ -98,4 +156,4 @@ Initial public release introducing the new column-based UI with resizable column
- Bump @api.global/typedserver to v8.3.0 (includes noCache feature)
- chore: initial project scaffold
- Initial commit and project scaffolding (summary)
- Initial commit and project scaffolding (summary)

View File

@@ -6,9 +6,7 @@
"to": "./ts/bundled_ui.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"includeFiles": [
"html/**/*"
]
"includeFiles": ["html/**/*"]
}
]
},
@@ -29,19 +27,14 @@
]
},
"@git.zone/tsview": {
"port": 3010,
"killIfBusy": true,
"port": 3010,
"killIfBusy": true,
"openBrowser": false
},
"@git.zone/cli": {
"services": [
"mongodb",
"minio"
],
"services": ["mongodb", "minio"],
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"registries": ["https://verdaccio.lossless.digital"],
"accessLevel": "public"
},
"projectType": "npm",
@@ -55,4 +48,4 @@
}
},
"@ship.zone/szci": {}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tsview",
"version": "1.5.0",
"version": "1.8.1",
"private": false,
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
"main": "dist_ts/index.js",
@@ -13,7 +13,8 @@
"build": "pnpm run bundle && tsbuild --allowimplicitany",
"bundle": "tsbundle",
"startTs": "node cli.ts.js",
"watch": "tswatch"
"watch": "tswatch",
"buildDocs": "tsdoc"
},
"bin": {
"tsview": "cli.js"
@@ -23,7 +24,7 @@
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.7",
"@git.zone/tstest": "^3.1.8",
"@git.zone/tswatch": "3.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@types/node": "^25.0.10"
@@ -33,8 +34,8 @@
"@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.1",
"@design.estate/dees-element": "^2.1.5",
"@design.estate/dees-catalog": "^3.41.1",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/early": "^4.0.4",
"@push.rocks/npmextra": "^5.3.3",
"@push.rocks/smartbucket": "^4.4.1",
@@ -67,5 +68,12 @@
"repository": {
"type": "git",
"url": "https://code.foss.global/git.zone/tsview.git"
},
"bugs": {
"url": "https://code.foss.global/git.zone/tsview/issues"
},
"homepage": "https://code.foss.global/git.zone/tsview#readme",
"pnpm": {
"overrides": {}
}
}

76
pnpm-lock.yaml generated
View File

@@ -21,11 +21,11 @@ importers:
specifier: ^3.975.0
version: 3.975.0
'@design.estate/dees-catalog':
specifier: ^3.37.1
version: 3.37.1(@tiptap/pm@2.27.2)
specifier: ^3.41.1
version: 3.41.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.1.5
version: 2.1.5
specifier: ^2.1.6
version: 2.1.6
'@push.rocks/early':
specifier: ^4.0.4
version: 4.0.4
@@ -79,8 +79,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1
'@git.zone/tstest':
specifier: ^3.1.7
version: 3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
specifier: ^3.1.8
version: 3.1.8(@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)
@@ -325,26 +325,26 @@ 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==}
'@cloudflare/workers-types@4.20260127.0':
resolution: {integrity: sha512-4M1HLcWViSdT/pAeDGEB5x5P3sqW7UIi34QrBRnxXbqjAY9if8vBU/lWRWnM+UqKzxWGB2LYjEVOzZrp0jZL+w==}
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.37.1':
resolution: {integrity: sha512-NCgzzCG3NJVF7C7aa1nExCMhB+7nA6glFgZpsff32CpvdtbAuBQiuOngU0suVw65uK7Y0a2r/y2CEPGNNmj3TA==}
'@design.estate/dees-catalog@3.41.1':
resolution: {integrity: sha512-AMD0VNLQEWXYRUYWwjLA8K8KEKAiUO7GiriWQm+ld7cj+LrCMsJO0jjVfOCsd4G7fURMqmab9ereBJyxqjoFgQ==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
'@design.estate/dees-domtools@2.3.7':
resolution: {integrity: sha512-MXoDBrP7JTOpni8b12aFXHJKnKBoQppM8cYBuL9cesRmCVGdB7p39XMRQ7dRyMhmmyr66L3cOczhiCV6febCwg==}
'@design.estate/dees-domtools@2.3.8':
resolution: {integrity: sha512-jUG9GMvPxKMwmRIZ9oLTL3c8hHvHuiwIk8cTrYnuZzGO/uJJ5/czk9o6LRXUuCOOG7TRLtqgOpK8EEQgaadfZA==}
'@design.estate/dees-element@2.1.5':
resolution: {integrity: sha512-czUOFvBiUKi34I+/keDRDc71fuORZS0NfbSuD2jJ4D1ODiTPjaZ6A6SkdQ2QqCEzVsx73XF99Pu8pxPnaOLnHg==}
'@design.estate/dees-element@2.1.6':
resolution: {integrity: sha512-7zyHkUjB8UEQgT9VbB2IJtc/yuPt9CI5JGel3b6BxA1kecY64ceIjFvof1uIkc0QP8q2fMLLY45r1c+9zDTjzg==}
'@design.estate/dees-wcctools@3.7.1':
resolution: {integrity: sha512-BiNWghUoC05RTQOGVCTK+wis6d18LyLY+2p8vHC0q2OBw9hrPoY8k9EplpQgY40MvP0sTXWUwaa7VPXra8ASjA==}
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -547,8 +547,8 @@ packages:
resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==}
hasBin: true
'@git.zone/tstest@3.1.7':
resolution: {integrity: sha512-YCDA+65LJhoY3WJxrNduKlpGf37aq4bFe+fdRqE0dZ2W1f7j3sUunBaBzckShSHKRjkMdPZKr0W0sXFXUK/PcA==}
'@git.zone/tstest@3.1.8':
resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==}
hasBin: true
'@git.zone/tswatch@3.0.1':
@@ -1924,8 +1924,8 @@ packages:
bare-abort-controller:
optional: true
bare-fs@4.5.2:
resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==}
bare-fs@4.5.3:
resolution: {integrity: sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
@@ -4017,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.20260124.0
'@cloudflare/workers-types': 4.20260127.0
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.2.0
@@ -4066,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.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.41.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
@@ -4688,17 +4688,17 @@ snapshots:
'@cloudflare/workers-types@4.20260123.0': {}
'@cloudflare/workers-types@4.20260124.0': {}
'@cloudflare/workers-types@4.20260127.0': {}
'@configvault.io/interfaces@1.0.17':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.37.1(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.41.1(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-element': 2.1.5
'@design.estate/dees-wcctools': 3.7.1
'@design.estate/dees-domtools': 2.3.8
'@design.estate/dees-element': 2.1.6
'@design.estate/dees-wcctools': 3.8.0
'@fortawesome/fontawesome-svg-core': 7.1.0
'@fortawesome/free-brands-svg-icons': 7.1.0
'@fortawesome/free-regular-svg-icons': 7.1.0
@@ -4736,7 +4736,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
broadcast-channel: 7.3.0
'@design.estate/dees-domtools@2.3.7':
'@design.estate/dees-domtools@2.3.8':
dependencies:
'@api.global/typedrequest': 3.2.5
'@design.estate/dees-comms': 1.0.30
@@ -4762,9 +4762,9 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-element@2.1.5':
'@design.estate/dees-element@2.1.6':
dependencies:
'@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-domtools': 2.3.8
'@push.rocks/isounique': 1.0.5
'@push.rocks/smartrx': 3.0.10
lit: 3.3.2
@@ -4774,10 +4774,10 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.7.1':
'@design.estate/dees-wcctools@3.8.0':
dependencies:
'@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-element': 2.1.5
'@design.estate/dees-domtools': 2.3.8
'@design.estate/dees-element': 2.1.6
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.2
transitivePeerDependencies:
@@ -4973,7 +4973,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.0
tsx: 4.21.0
'@git.zone/tstest@3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)':
'@git.zone/tstest@3.1.8(@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
@@ -5882,7 +5882,7 @@ snapshots:
'@push.rocks/smartntml@2.0.8':
dependencies:
'@design.estate/dees-element': 2.1.5
'@design.estate/dees-element': 2.1.6
'@happy-dom/global-registrator': 15.11.7
'@push.rocks/smartpromise': 4.2.3
fake-indexeddb: 6.2.5
@@ -6130,7 +6130,7 @@ snapshots:
'@push.rocks/taskbuffer@3.5.0':
dependencies:
'@design.estate/dees-element': 2.1.5
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
@@ -7139,7 +7139,7 @@ snapshots:
bare-events@2.8.2: {}
bare-fs@4.5.2:
bare-fs@4.5.3:
dependencies:
bare-events: 2.8.2
bare-path: 3.0.0
@@ -9319,7 +9319,7 @@ snapshots:
pump: 3.0.3
tar-stream: 3.1.7
optionalDependencies:
bare-fs: 4.5.2
bare-fs: 4.5.3
bare-path: 3.0.0
transitivePeerDependencies:
- bare-abort-controller

View File

@@ -1,37 +1,44 @@
# tsview - Project Hints
## Overview
tsview is a CLI tool for viewing S3 and MongoDB data through a web UI.
## Key Patterns
### Configuration
- Reads from `.nogit/env.json` (created by `gitzone service`)
- Environment variables: MONGODB_URL, S3_HOST, S3_ACCESSKEY, etc.
### CLI Commands
- `tsview` - Start viewer (auto-finds free port from 3010+)
- `tsview --port 3000` - Force specific port
- `tsview s3` - S3 viewer only
- `tsview mongo` - MongoDB viewer only
### Dependencies
- Uses `@push.rocks/smartbucket` for S3 operations
- Uses `@push.rocks/smartdata` for MongoDB operations
- Uses `@api.global/typedserver` + `@api.global/typedrequest` for API
- Uses `@design.estate/dees-catalog` for UI components
### Build Process
- Run `pnpm build` to compile TypeScript and bundle web UI
- UI is bundled from `ts_web/` to `ts/bundled_ui.ts` as base64
### Web UI Structure
- `ts_web/elements/` - Web components (LitElement-based)
- `ts_web/services/` - API service for backend communication
- `ts_web/utilities/` - Shared formatting functions (formatSize, formatCount, getFileName)
- `ts_web/styles/` - Shared CSS custom properties (themeStyles)
### TypedRequest Pattern
```typescript
// Interface definition
export interface IReq_ListBuckets extends plugins.typedrequest.implementsTR<
@@ -57,27 +64,32 @@ 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 }
@@ -94,6 +106,29 @@ IReq_PushS3Change: { event: IS3ChangeEvent } -> { received }
IReq_PushActivityEvent: { event: IActivityEvent } -> { received }
```
### WebSocket Context Pattern
When a TypedHandler receives a WebSocket request, the transport context is available via the second argument (`TypedTools` instance). SmartServe attaches the WebSocket peer to `localData.peer`:
```typescript
// In a TypedHandler callback:
async (reqData, context) => {
// context is a TypedTools instance
const peerId = context.localData?.peer?.id; // unique WebSocket connection ID
}
```
To push events back to a specific client, use `findTargetConnection`:
```typescript
const conn = await typedSocket.findTargetConnection(async (c: any) => {
return c.peer?.id === connectionId;
});
const request = typedSocket.createTypedRequest<IReq_Push>('pushEvent', conn);
await request.fire({ event });
```
### Dependencies Added
- `@api.global/typedsocket` - WebSocket client/server
- `@push.rocks/smartrx` - RxJS utilities

244
readme.md
View File

@@ -1,6 +1,6 @@
# @git.zone/tsview
A powerful developer tool for browsing and managing S3-compatible storage and MongoDB databases through a sleek web UI. Built with TypeScript, designed for developers who need quick, visual access to their data stores during development. 🚀
A powerful developer tool for browsing and managing S3-compatible storage and MongoDB databases through a sleek web UI — with real-time change streaming baked in. Built with TypeScript, designed for developers who need quick, visual access to their data stores. 🚀
## Issue Reporting and Security
@@ -10,41 +10,51 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
```bash
# Global installation (recommended for CLI usage)
npm install -g @git.zone/tsview
# or
pnpm add -g @git.zone/tsview
# Local installation (for programmatic usage)
npm install @git.zone/tsview
# or
pnpm add @git.zone/tsview
```
## Features ✨
### 🗄️ S3 Storage Browser
- **Column View Navigation** - Mac Finder-style interface with resizable columns for intuitive file browsing
- **List View** - Traditional key-based view with hierarchical navigation
- **Real-time Preview** - View images, JSON, text files, and more directly in the browser
- **Bucket Management** - Create, delete, and switch between buckets
- **File Operations** - Upload, download, delete objects with ease
- **Smart Content Type Detection** - Automatic content type recognition for 20+ file types
- **Breadcrumb Navigation** - Easy path traversal with clickable breadcrumbs
- **Column View Navigation** — Mac Finder-style interface with resizable columns
- **List View** — Traditional key-based view with hierarchical navigation
- **Real-time Preview** — View images, JSON, text files, code, and more directly in the browser
- **Bucket Management** — Create, delete, and switch between buckets
- **File Operations** — Upload, download, delete objects
- **In-place Text Editing** Edit text files directly in the browser with change tracking
- **Smart Content Type Detection** — Automatic recognition for 20+ file types
- **Breadcrumb Navigation** — Clickable path traversal
### 🍃 MongoDB Browser
- **Database Explorer** - Hierarchical navigation through databases and collections
- **Document Viewer** - Paginated table view with sorting and filtering
- **Document Editor** - Full CRUD operations with JSON syntax highlighting
- **Index Management** - View, create, and drop indexes
- **Aggregation Pipeline** - Run custom aggregation queries (coming soon)
- **Collection Stats** - View document counts, sizes, and storage metrics
- **Server Status** - Monitor connection info and server health
- **Database Explorer** — Hierarchical navigation through databases and collections
- **Database Overview** — Collection counts, data sizes, index stats at a glance
- **Document Viewer** — Paginated table view with JSON filter support
- **Document Editor** — Full CRUD with syntax-highlighted code editor and change tracking
- **Index Management** View, create, and drop indexes
- **Collection Stats** — Document counts, sizes, storage metrics
- **Server Status** — Connection info, version, uptime
- **Show/Hide System Databases** — Toggle visibility of `admin`, `local`, `config`
### ⚡ Real-Time Change Streaming
- **MongoDB Change Streams** — Live updates via native MongoDB change streams
- **S3 Change Detection** — Polling-based bucket monitoring with ETag comparison (5s intervals)
- **Activity Stream** — Combined timeline of all changes from both sources, filterable by type
- **Live Indicators** — Green dot + change count badges on active views
- **WebSocket Subscriptions** — Per-collection, per-bucket, or global activity feed
- **Auto-Reconnect** — Subscriptions automatically restored after connection loss
### 🎨 Modern Web UI
- 🌙 Dark theme designed for developer comfort
- 📱 Responsive layout with resizable panels
- ⌨️ Keyboard-friendly navigation
- 🔌 Zero external runtime dependencies in the browser
- ⌨️ Context menus for quick actions
- 🔌 Everything bundled — zero external runtime dependencies in the browser
## Quick Start 🚀
@@ -90,28 +100,6 @@ tsview mongo
tsview mongodb
```
## Configuration via npmextra.json
For project-level configuration, add a `@git.zone/tsview` section to your `npmextra.json`:
```json
{
"@git.zone/tsview": {
"port": 3015,
"killIfBusy": true,
"openBrowser": false
}
}
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `port` | `number` | auto | Fixed port to use (auto-finds from 3010 if not set) |
| `killIfBusy` | `boolean` | `false` | Kill existing process if port is busy |
| `openBrowser` | `boolean` | `true` | Automatically open browser on start |
**Priority order:** CLI `--port` flag > `npmextra.json` config > auto-detect
## Programmatic API
Use tsview as a library in your own tools:
@@ -124,32 +112,32 @@ const viewer = new TsView();
// Option 1: Load from .nogit/env.json (gitzone service format)
await viewer.loadConfigFromEnv();
// Option 2: Configure programmatically for local development
// Option 2: Configure programmatically
viewer.setS3Config({
endpoint: 'localhost',
port: 9000,
accessKey: 'minioadmin',
accessSecret: 'minioadmin',
useSsl: false
useSsl: false,
});
viewer.setMongoConfig({
mongoDbUrl: 'mongodb://localhost:27017',
mongoDbName: 'mydb'
mongoDbName: 'mydb',
});
// Option 3: Configure for cloud services
// Option 3: Cloud services
viewer.setS3Config({
endpoint: 's3.amazonaws.com',
accessKey: 'AKIAXXXXXXX',
accessSecret: 'your-secret-key',
useSsl: true,
region: 'us-east-1'
region: 'us-east-1',
});
viewer.setMongoConfig({
mongoDbUrl: 'mongodb+srv://user:pass@cluster.mongodb.net',
mongoDbName: 'production'
mongoDbName: 'production',
});
// Start the server
@@ -163,62 +151,120 @@ await viewer.start(3500);
await viewer.stop();
```
## Environment Variables
## Configuration
The following environment variables are supported in `.nogit/env.json`:
### Project-level via `npmextra.json`
### S3 Configuration
| Variable | Description |
|----------|-------------|
| `S3_ENDPOINT` | S3 server hostname |
| `S3_PORT` | S3 server port (optional) |
| `S3_ACCESSKEY` | Access key ID |
| `S3_SECRETKEY` | Secret access key |
| `S3_USESSL` | Use HTTPS (`true`/`false`) |
```json
{
"@git.zone/tsview": {
"port": 3015,
"killIfBusy": true,
"openBrowser": false
}
}
```
### MongoDB Configuration
| Variable | Description |
|----------|-------------|
| `MONGODB_URL` | Full MongoDB connection string |
| `MONGODB_NAME` | Default database name |
| Option | Type | Default | Description |
| ------------- | --------- | ------- | -------------------------------------------- |
| `port` | `number` | auto | Fixed port (auto-finds from 3010 if not set) |
| `killIfBusy` | `boolean` | `false` | Kill existing process if port is busy |
| `openBrowser` | `boolean` | `true` | Automatically open browser on start |
Or use individual MongoDB variables:
| Variable | Description |
|----------|-------------|
| `MONGODB_HOST` | MongoDB hostname |
| `MONGODB_PORT` | MongoDB port |
| `MONGODB_USER` | Username |
| `MONGODB_PASS` | Password |
**Port priority:** CLI `--port` flag → `npmextra.json` → auto-detect
### Environment Variables (`.nogit/env.json`)
#### S3
| Variable | Description |
| -------------- | ----------------------------- |
| `S3_ENDPOINT` | S3-compatible server hostname |
| `S3_PORT` | Server port (optional) |
| `S3_ACCESSKEY` | Access key ID |
| `S3_SECRETKEY` | Secret access key |
| `S3_USESSL` | Use HTTPS (`true`/`false`) |
#### MongoDB
| Variable | Description |
| -------------- | ---------------------------------- |
| `MONGODB_URL` | Full connection string (preferred) |
| `MONGODB_NAME` | Default database name |
Or use individual variables:
| Variable | Description |
| -------------- | ------------- |
| `MONGODB_HOST` | Hostname |
| `MONGODB_PORT` | Port |
| `MONGODB_USER` | Username |
| `MONGODB_PASS` | Password |
| `MONGODB_NAME` | Database name |
## Supported S3 Providers
tsview works with any S3-compatible storage:
| Provider | Status |
|----------|--------|
| **MinIO** | ✅ Perfect for local development |
| **AWS S3** | ✅ Amazon's object storage |
| **DigitalOcean Spaces** | ✅ Simple object storage |
| **Backblaze B2** | ✅ S3-compatible API |
| **Cloudflare R2** | ✅ Zero egress fees |
| **Wasabi** | ✅ Hot cloud storage |
| **Self-hosted** | ✅ Any S3-compatible server |
| Provider | Status |
| ----------------------- | --------------------------- |
| **MinIO** | ✅ Perfect for local dev |
| **AWS S3** | ✅ Amazon's object storage |
| **DigitalOcean Spaces** | ✅ Simple object storage |
| **Backblaze B2** | ✅ S3-compatible API |
| **Cloudflare R2** | ✅ Zero egress fees |
| **Wasabi** | ✅ Hot cloud storage |
| **Self-hosted** | ✅ Any S3-compatible server |
## Supported File Types for Preview
| Category | Extensions |
|----------|------------|
| **Images** | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg` |
| **Text** | `.txt`, `.md`, `.log`, `.sh`, `.env` |
| **Code** | `.json`, `.js`, `.ts`, `.tsx`, `.jsx`, `.html`, `.css` |
| **Data** | `.csv`, `.xml`, `.yaml`, `.yml` |
| **Documents** | `.pdf` |
| Category | Extensions |
| ------------- | ------------------------------------------------------ |
| **Images** | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg` |
| **Text** | `.txt`, `.md`, `.log`, `.sh`, `.env` |
| **Code** | `.json`, `.js`, `.ts`, `.tsx`, `.jsx`, `.html`, `.css` |
| **Data** | `.csv`, `.xml`, `.yaml`, `.yml` |
| **Documents** | `.pdf` |
## Architecture
```
tsview/
├── ts/ # Backend
│ ├── api/ # TypedRequest API handlers
│ │ ├── handlers.s3.ts # S3 bucket & object operations
│ │ └── handlers.mongodb.ts # MongoDB CRUD & admin operations
│ ├── config/ # Configuration management
│ ├── server/ # Web server (TypedServer + TypedSocket)
│ ├── streaming/ # Real-time change streaming
│ │ ├── classes.changestream-manager.ts # MongoDB + S3 watchers
│ │ └── interfaces.streaming.ts # Subscription interfaces
│ ├── interfaces/ # Shared TypeScript interfaces
│ └── tsview.classes.tsview.ts # Main class
├── ts_web/ # Frontend
│ ├── elements/ # Web components (LitElement)
│ │ ├── tsview-app.ts # App shell + navigation
│ │ ├── tsview-s3-*.ts # S3 browser components
│ │ ├── tsview-mongo-*.ts # MongoDB browser components
│ │ └── tsview-activity-stream.ts # Real-time activity feed
│ ├── services/ # API + WebSocket clients
│ ├── styles/ # Dark theme
│ └── utilities/ # Formatting helpers
└── cli.ts.js # CLI entry point
```
### How It Works
1. **Backend** — A `TypedServer` serves the bundled web UI and exposes a typed API via `TypedRequest` over HTTP. A `TypedSocket` WebSocket layer handles real-time streaming subscriptions.
2. **Frontend** — LitElement-based web components communicate with the backend via `TypedRequest`. The `ChangeStreamService` connects over WebSocket and distributes real-time events to active views via RxJS Subjects.
3. **Streaming** — The `ChangeStreamManager` creates MongoDB Change Streams and S3 BucketWatchers on demand (one per subscribed resource). Changes are pushed to subscribed clients and accumulated in a 1000-event ring buffer for the Activity Stream view.
## Development
```bash
# Clone the repository
# Clone
git clone https://code.foss.global/git.zone/tsview.git
cd tsview
@@ -235,29 +281,9 @@ pnpm run watch
pnpm test
```
## Architecture
```
tsview/
├── ts/ # Backend TypeScript source
│ ├── api/ # TypedRequest API handlers
│ │ ├── handlers.s3.ts
│ │ └── handlers.mongodb.ts
│ ├── config/ # Configuration management
│ ├── server/ # Web server (TypedServer)
│ ├── interfaces/ # Shared TypeScript interfaces
│ └── tsview.classes.tsview.ts # Main class
├── ts_web/ # Frontend TypeScript source
│ ├── elements/ # Web components (LitElement)
│ ├── services/ # API client service
│ ├── styles/ # Shared theme styles
│ └── utilities/ # Helper functions
└── cli.ts.js # CLI entry point
```
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

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

View File

@@ -1,13 +1,15 @@
import * as plugins from '../plugins.js';
import type * as interfaces from '../interfaces/index.js';
import type { TsView } from '../tsview.classes.tsview.js';
import type { ChangeStreamManager } from '../streaming/index.js';
/**
* Register MongoDB API handlers
*/
export async function registerMongoHandlers(
typedrouter: plugins.typedrequest.TypedRouter,
tsview: TsView
tsview: TsView,
changeStreamManager?: ChangeStreamManager
): Promise<void> {
// Helper to get the native MongoDB client
const getMongoClient = async () => {
@@ -122,6 +124,14 @@ export async function registerMongoHandlers(
const db = client.db(reqData.databaseName);
// Create a placeholder collection to materialize the database
await db.createCollection('_tsview_init');
if (changeStreamManager) {
changeStreamManager.emitMongoActivityEvent({
type: 'insert',
database: reqData.databaseName,
collection: '_tsview_init',
timestamp: new Date().toISOString(),
});
}
return { success: true };
} catch (err) {
console.error('Error creating database:', err);
@@ -140,6 +150,14 @@ export async function registerMongoHandlers(
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
await db.dropDatabase();
if (changeStreamManager) {
changeStreamManager.emitMongoActivityEvent({
type: 'drop',
database: reqData.databaseName,
collection: '*',
timestamp: new Date().toISOString(),
});
}
return { success: true };
} catch (err) {
console.error('Error dropping database:', err);
@@ -158,6 +176,14 @@ export async function registerMongoHandlers(
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
await db.createCollection(reqData.collectionName);
if (changeStreamManager) {
changeStreamManager.emitMongoActivityEvent({
type: 'insert',
database: reqData.databaseName,
collection: reqData.collectionName,
timestamp: new Date().toISOString(),
});
}
return { success: true };
} catch (err) {
console.error('Error creating collection:', err);
@@ -176,6 +202,14 @@ export async function registerMongoHandlers(
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
await db.dropCollection(reqData.collectionName);
if (changeStreamManager) {
changeStreamManager.emitMongoActivityEvent({
type: 'drop',
database: reqData.databaseName,
collection: reqData.collectionName,
timestamp: new Date().toISOString(),
});
}
return { success: true };
} catch (err) {
console.error('Error dropping collection:', err);
@@ -267,6 +301,16 @@ export async function registerMongoHandlers(
const result = await collection.insertOne(reqData.document);
if (changeStreamManager) {
changeStreamManager.emitMongoActivityEvent({
type: 'insert',
database: reqData.databaseName,
collection: reqData.collectionName,
documentId: result.insertedId.toString(),
timestamp: new Date().toISOString(),
});
}
return { insertedId: result.insertedId.toString() };
} catch (err) {
console.error('Error inserting document:', err);
@@ -294,6 +338,16 @@ export async function registerMongoHandlers(
const result = await collection.updateOne(filter, updateDoc);
if (changeStreamManager && (result.modifiedCount > 0 || result.matchedCount > 0)) {
changeStreamManager.emitMongoActivityEvent({
type: 'update',
database: reqData.databaseName,
collection: reqData.collectionName,
documentId: reqData.documentId,
timestamp: new Date().toISOString(),
});
}
return {
success: result.modifiedCount > 0 || result.matchedCount > 0,
modifiedCount: result.modifiedCount,
@@ -319,6 +373,16 @@ export async function registerMongoHandlers(
const filter = createIdFilter(reqData.documentId);
const result = await collection.deleteOne(filter);
if (changeStreamManager && result.deletedCount > 0) {
changeStreamManager.emitMongoActivityEvent({
type: 'delete',
database: reqData.databaseName,
collection: reqData.collectionName,
documentId: reqData.documentId,
timestamp: new Date().toISOString(),
});
}
return {
success: result.deletedCount > 0,
deletedCount: result.deletedCount,

File diff suppressed because one or more lines are too long

View File

@@ -35,18 +35,18 @@ export class ViewServer {
noCache: true,
});
// Initialize ChangeStreamManager for real-time updates (before handlers so they can emit events)
this.changeStreamManager = new ChangeStreamManager(this.tsview);
// Register API handlers directly to server's router
if (this.tsview.config.hasS3()) {
await registerS3Handlers(this.typedServer.typedrouter, this.tsview);
}
if (this.tsview.config.hasMongo()) {
await registerMongoHandlers(this.typedServer.typedrouter, this.tsview);
await registerMongoHandlers(this.typedServer.typedrouter, this.tsview, this.changeStreamManager);
}
// Initialize ChangeStreamManager for real-time updates
this.changeStreamManager = new ChangeStreamManager(this.tsview);
// Register streaming handlers
await this.registerStreamingHandlers();
@@ -192,18 +192,16 @@ export class ViewServer {
}
/**
* Extract connection ID from request context
* Extract connection ID from request context.
* SmartServe attaches the WebSocket peer to `localData.peer` on each request.
*/
private getConnectionId(context: any): string | null {
// Try to get connection ID from WebSocket context
if (context?.socketConnection?.socketId) {
return context.socketConnection.socketId;
// The TypedTools instance carries localData from the transport layer.
// SmartServe puts the IWebSocketPeer at localData.peer.
if (context?.localData?.peer?.id) {
return context.localData.peer.id;
}
if (context?.socketConnection?.alias) {
return context.socketConnection.alias;
}
// Fallback: generate a unique ID for HTTP requests
// Note: Real-time streaming requires WebSocket connection
// HTTP requests don't have a peer — real-time streaming requires WebSocket.
return null;
}

View File

@@ -55,6 +55,11 @@ export class ChangeStreamManager {
private activityBuffer: interfaces.IActivityEvent[] = [];
private readonly ACTIVITY_BUFFER_SIZE = 1000;
// Global watchers for the activity stream (started lazily on first subscriber)
private globalMongoWatcher: plugins.mongodb.ChangeStream | null = null;
private globalS3Watchers: Map<string, plugins.smartbucket.BucketWatcher> = new Map();
private globalWatchersActive: boolean = false;
// Counter for generating unique subscription IDs
private subscriptionCounter = 0;
@@ -218,8 +223,11 @@ export class ChangeStreamManager {
timestamp: new Date().toISOString(),
};
// Add to activity buffer
this.addToActivityBuffer('mongodb', event);
// Only add to activity buffer if global watchers are NOT active.
// When active, the global MongoDB watcher already feeds the activity stream.
if (!this.globalWatchersActive) {
this.addToActivityBuffer('mongodb', event);
}
// Push to all subscribed clients
this.pushMongoChangeToClients(key, event);
@@ -239,7 +247,7 @@ export class ChangeStreamManager {
try {
// Find the connection and push the event
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
return conn.peer?.id === connectionId;
});
if (connection) {
@@ -391,8 +399,11 @@ export class ChangeStreamManager {
if (!entry) return;
// Add to activity buffer
this.addToActivityBuffer('s3', event);
// Only add to activity buffer if global watchers are NOT active.
// When active, the global S3 watchers already feed the activity stream.
if (!this.globalWatchersActive) {
this.addToActivityBuffer('s3', event);
}
// Push to all subscribed clients
this.pushS3ChangeToClients(key, event);
@@ -411,7 +422,7 @@ export class ChangeStreamManager {
for (const [connectionId, _sub] of entry.subscriptions) {
try {
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
return conn.peer?.id === connectionId;
});
if (connection) {
@@ -460,6 +471,12 @@ export class ChangeStreamManager {
});
console.log(`[ChangeStream] Activity subscription added for connection ${connectionId}`);
// Start global watchers when the first activity subscriber connects
if (this.activitySubscribers.size === 1) {
await this.startGlobalWatchers();
}
return { success: true, subscriptionId };
}
@@ -470,6 +487,11 @@ export class ChangeStreamManager {
const result = this.activitySubscribers.delete(connectionId);
if (result) {
console.log(`[ChangeStream] Activity subscription removed for connection ${connectionId}`);
// Stop global watchers when no activity subscribers remain
if (this.activitySubscribers.size === 0) {
await this.stopGlobalWatchers();
}
}
return result;
}
@@ -482,6 +504,13 @@ export class ChangeStreamManager {
return this.activityBuffer.slice(-count);
}
/**
* Emit a MongoDB activity event from an API handler (no change stream required).
*/
public emitMongoActivityEvent(event: interfaces.IMongoChangeEvent): void {
this.addToActivityBuffer('mongodb', event);
}
/**
* Add an event to the activity buffer
*/
@@ -516,7 +545,7 @@ export class ChangeStreamManager {
for (const [connectionId, _sub] of this.activitySubscribers) {
try {
const connection = await this.typedSocket.findTargetConnection(async (conn: any) => {
return conn.alias === connectionId || conn.socketId === connectionId;
return conn.peer?.id === connectionId;
});
if (connection) {
@@ -532,6 +561,154 @@ export class ChangeStreamManager {
}
}
// ===========================================
// Global Watchers for Activity Stream
// ===========================================
/**
* Start global watchers when the first activity subscriber connects.
* These watch all MongoDB and S3 activity and feed into the activity buffer.
*/
private async startGlobalWatchers(): Promise<void> {
if (this.globalWatchersActive) return;
this.globalWatchersActive = true;
console.log('[ChangeStream] Starting global watchers for activity stream...');
await Promise.all([
this.startGlobalMongoWatcher(),
this.startGlobalS3Watchers(),
]);
}
/**
* Start a deployment-level MongoDB change stream that watches ALL databases/collections.
*/
private async startGlobalMongoWatcher(): Promise<void> {
try {
const db = await this.tsview.getMongoDb();
if (!db) {
console.log('[ChangeStream] MongoDB not configured, skipping global MongoDB watcher');
return;
}
const client = (db as any).mongoDbClient as plugins.mongodb.MongoClient;
// Deployment-level watch — one stream for everything
const changeStream = client.watch([], {
fullDocument: 'updateLookup',
});
changeStream.on('change', (change: any) => {
const database = change.ns?.db || 'unknown';
const collection = change.ns?.coll || 'unknown';
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(),
};
this.addToActivityBuffer('mongodb', event);
});
changeStream.on('error', (error: Error) => {
console.error('[ChangeStream] Global MongoDB watcher error:', error);
});
this.globalMongoWatcher = changeStream;
console.log('[ChangeStream] Global MongoDB watcher started');
} catch (error) {
console.warn('[ChangeStream] MongoDB change streams unavailable (requires replica set). MongoDB activity events will come from API operations only.');
}
}
/**
* Start S3 bucket watchers — one BucketWatcher per bucket.
*/
private async startGlobalS3Watchers(): Promise<void> {
try {
const smartbucket = await this.tsview.getSmartBucket();
if (!smartbucket) {
console.log('[ChangeStream] S3 not configured, skipping global S3 watchers');
return;
}
// List all buckets
const command = new plugins.s3.ListBucketsCommand({});
const response = await smartbucket.s3Client.send(command) as plugins.s3.ListBucketsCommandOutput;
const bucketNames = response.Buckets?.map(b => b.Name).filter((name): name is string => !!name) || [];
for (const bucketName of bucketNames) {
try {
const bucketInstance = await smartbucket.getBucketByName(bucketName);
const watcher = bucketInstance.createWatcher({
prefix: '',
pollIntervalMs: 5000,
bufferTimeMs: 500,
});
watcher.changeSubject.subscribe((eventOrEvents: IS3ChangeEvent | IS3ChangeEvent[]) => {
const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents];
for (const event of events) {
this.addToActivityBuffer('s3', event);
}
});
await watcher.start();
await watcher.readyDeferred.promise;
this.globalS3Watchers.set(bucketName, watcher);
console.log(`[ChangeStream] Global S3 watcher started for bucket: ${bucketName}`);
} catch (bucketError) {
console.error(`[ChangeStream] Failed to start global S3 watcher for bucket ${bucketName}:`, bucketError);
}
}
console.log(`[ChangeStream] Global S3 watchers started (${this.globalS3Watchers.size}/${bucketNames.length} buckets)`);
} catch (error) {
console.error('[ChangeStream] Failed to start global S3 watchers:', error);
}
}
/**
* Stop all global watchers when no activity subscribers remain.
*/
private async stopGlobalWatchers(): Promise<void> {
if (!this.globalWatchersActive) return;
console.log('[ChangeStream] Stopping global watchers...');
// Close global MongoDB watcher
if (this.globalMongoWatcher) {
try {
await this.globalMongoWatcher.close();
console.log('[ChangeStream] Global MongoDB watcher stopped');
} catch (error) {
console.error('[ChangeStream] Error closing global MongoDB watcher:', error);
}
this.globalMongoWatcher = null;
}
// Close all global S3 watchers
for (const [bucketName, watcher] of this.globalS3Watchers) {
try {
await watcher.stop();
console.log(`[ChangeStream] Global S3 watcher stopped for bucket: ${bucketName}`);
} catch (error) {
console.error(`[ChangeStream] Error closing global S3 watcher for ${bucketName}:`, error);
}
}
this.globalS3Watchers.clear();
this.globalWatchersActive = false;
console.log('[ChangeStream] Global watchers stopped');
}
// ===========================================
// Connection Management
// ===========================================
@@ -564,6 +741,11 @@ export class ChangeStreamManager {
// Clean up activity subscription
this.activitySubscribers.delete(connectionId);
// Stop global watchers if no activity subscribers remain
if (this.activitySubscribers.size === 0) {
await this.stopGlobalWatchers();
}
}
/**
@@ -572,6 +754,9 @@ export class ChangeStreamManager {
public async stop(): Promise<void> {
console.log('[ChangeStream] Stopping all watchers...');
// Stop global watchers first
await this.stopGlobalWatchers();
// Close all MongoDB watchers
for (const key of this.mongoWatchers.keys()) {
await this.closeMongoWatcher(key);

View File

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

View File

@@ -23,8 +23,12 @@ export class TsviewActivityStream extends DeesElement {
@state()
private accessor autoScroll: boolean = true;
@state()
private accessor now: number = Date.now();
private subscription: plugins.smartrx.rxjs.Subscription | null = null;
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
private nowInterval: ReturnType<typeof setInterval> | null = null;
public static styles = [
cssManager.defaultStyles,
@@ -159,6 +163,15 @@ export class TsviewActivityStream extends DeesElement {
background: rgba(255, 255, 255, 0.05);
}
.event-time-col {
width: 70px;
flex-shrink: 0;
text-align: right;
font-size: 11px;
color: #666;
padding-top: 2px;
}
.event-icon {
width: 36px;
height: 36px;
@@ -192,7 +205,6 @@ export class TsviewActivityStream extends DeesElement {
.event-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
@@ -202,11 +214,6 @@ export class TsviewActivityStream extends DeesElement {
color: #e0e0e0;
}
.event-time {
font-size: 11px;
color: #666;
}
.event-details {
font-size: 12px;
color: #888;
@@ -285,50 +292,66 @@ export class TsviewActivityStream extends DeesElement {
async connectedCallback() {
super.connectedCallback();
this.nowInterval = setInterval(() => {
this.now = Date.now();
}, 1000);
await this.initializeStreaming();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.nowInterval) {
clearInterval(this.nowInterval);
this.nowInterval = null;
}
this.cleanup();
}
private async initializeStreaming() {
this.isLoading = true;
// Subscribe to connection status and trigger re-subscription on reconnect
this.connectionSubscription = changeStreamService.connectionStatus$.subscribe(async (status) => {
const wasConnected = this.isConnected;
this.isConnected = status === 'connected';
if (status === 'connected' && !wasConnected) {
await this.setupSubscriptions();
this.isLoading = false;
}
});
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;
// setupSubscriptions() is triggered by connectionStatus$ subscriber above
} catch (error) {
console.error('Failed to initialize activity stream:', error);
this.isConnected = false;
this.isLoading = false;
}
}
private async setupSubscriptions() {
// Read buffered events (captured while on other tabs by app-level subscription)
const buffered = changeStreamService.getBufferedActivity();
if (buffered.length > 0) {
this.events = buffered;
} else {
// Buffer empty (fresh start) — fetch from server
const recentEvents = await changeStreamService.getRecentActivity(100);
if (recentEvents.length > 0) {
this.events = recentEvents;
}
}
this.isLoading = false;
// Set up RxJS listener only once for new live events
if (!this.subscription) {
this.subscription = changeStreamService.getActivityStream().subscribe((event) => {
this.events = [...this.events, event].slice(-500);
if (this.autoScroll) {
this.scrollToTop();
}
});
}
}
private cleanup() {
@@ -340,14 +363,15 @@ export class TsviewActivityStream extends DeesElement {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = null;
}
changeStreamService.unsubscribeFromActivity();
// DO NOT call changeStreamService.unsubscribeFromActivity()
// The app-level subscription keeps the server sending events for buffering
}
private scrollToBottom() {
private scrollToTop() {
requestAnimationFrame(() => {
const list = this.shadowRoot?.querySelector('.events-list');
if (list) {
list.scrollTop = list.scrollHeight;
list.scrollTop = 0;
}
});
}
@@ -361,10 +385,11 @@ export class TsviewActivityStream extends DeesElement {
}
private get filteredEvents(): IActivityEvent[] {
if (this.filterMode === 'all') {
return this.events;
let events = this.events;
if (this.filterMode !== 'all') {
events = events.filter((e) => e.source === this.filterMode);
}
return this.events.filter((e) => e.source === this.filterMode);
return events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
private formatTime(timestamp: string): string {
@@ -378,11 +403,13 @@ export class TsviewActivityStream extends DeesElement {
private formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const diff = this.now - date.getTime();
if (diff < 60000) {
return 'just now';
if (diff < 1000) {
return 'now';
} else if (diff < 60000) {
const secs = Math.floor(diff / 1000);
return `${secs}s ago`;
} else if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
return `${mins}m ago`;
@@ -534,6 +561,9 @@ export class TsviewActivityStream extends DeesElement {
: this.filteredEvents.map(
(event) => html`
<div class="event-item" @click=${() => this.handleEventClick(event)}>
<div class="event-time-col" title=${this.formatTime(event.timestamp)}>
${this.formatRelativeTime(event.timestamp)}
</div>
<div class="event-icon ${event.source}">
${event.source === 'mongodb' ? this.renderMongoIcon() : this.renderS3Icon()}
</div>
@@ -543,9 +573,6 @@ export class TsviewActivityStream extends DeesElement {
<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>

View File

@@ -421,15 +421,18 @@ export class TsviewApp extends DeesElement {
async connectedCallback() {
super.connectedCallback();
await this.loadData();
// Initialize WebSocket connection for real-time updates
// Start WebSocket connection first (non-blocking) so it's in-flight
// before child components try to subscribe
this.initializeChangeStream();
await this.loadData();
}
private async initializeChangeStream() {
try {
await changeStreamService.connect();
console.log('[TsviewApp] ChangeStream connected');
// Subscribe to activity globally so events are buffered regardless of active tab
await changeStreamService.subscribeToActivity();
} catch (error) {
console.warn('[TsviewApp] Failed to connect to ChangeStream:', error);
}

View File

@@ -37,6 +37,7 @@ export class TsviewMongoBrowser extends DeesElement {
private accessor isStreamConnected: boolean = false;
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [
cssManager.defaultStyles,
@@ -201,12 +202,20 @@ export class TsviewMongoBrowser extends DeesElement {
async connectedCallback() {
super.connectedCallback();
await this.loadStats();
this.subscribeToChanges();
// Subscription is handled by updated() when databaseName/collectionName are set.
// Only track connection status for UI indicator here.
this.connectionSubscription = changeStreamService.connectionStatus$.subscribe((status) => {
this.isStreamConnected = status === 'connected';
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeFromChanges();
if (this.connectionSubscription) {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = null;
}
}
updated(changedProperties: Map<string, unknown>) {
@@ -224,18 +233,16 @@ export class TsviewMongoBrowser extends DeesElement {
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
// Set up RxJS listener first so events aren't missed on reconnect
if (!this.changeSubscription) {
this.changeSubscription = changeStreamService
.getCollectionChanges(this.databaseName, this.collectionName)
.subscribe((event) => {
this.handleChange(event);
});
.subscribe((event) => this.handleChange(event));
}
// Subscribe on the server side (will auto-connect if needed)
const success = await changeStreamService.subscribeToCollection(this.databaseName, this.collectionName);
this.isStreamConnected = success;
} catch (error) {
console.warn('[MongoBrowser] Failed to subscribe to changes:', error);
this.isStreamConnected = false;

View File

@@ -24,7 +24,7 @@ export class TsviewS3Browser extends DeesElement {
private accessor refreshKey: number = 0;
@state()
private accessor previewWidth: number = 350;
private accessor previewWidth: number = 700;
@state()
private accessor isResizingPreview: boolean = false;
@@ -36,6 +36,7 @@ export class TsviewS3Browser extends DeesElement {
private accessor isStreamConnected: boolean = false;
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [
cssManager.defaultStyles,
@@ -123,7 +124,7 @@ export class TsviewS3Browser extends DeesElement {
}
.content.has-preview {
grid-template-columns: 1fr 4px var(--preview-width, 350px);
grid-template-columns: 1fr 4px var(--preview-width, 700px);
}
.resize-divider {
@@ -209,12 +210,20 @@ export class TsviewS3Browser extends DeesElement {
async connectedCallback() {
super.connectedCallback();
this.subscribeToChanges();
// Subscription is handled by updated() when bucketName is set.
// Only track connection status for UI indicator here.
this.connectionSubscription = changeStreamService.connectionStatus$.subscribe((status) => {
this.isStreamConnected = status === 'connected';
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeFromChanges();
if (this.connectionSubscription) {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = null;
}
}
private setViewType(type: TViewType) {
@@ -256,18 +265,16 @@ export class TsviewS3Browser extends DeesElement {
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
// Set up RxJS listener first so events aren't missed on reconnect
if (!this.changeSubscription) {
this.changeSubscription = changeStreamService
.getBucketChanges(this.bucketName, this.currentPrefix || undefined)
.subscribe((event) => {
this.handleChange(event);
});
.subscribe((event) => this.handleChange(event));
}
// Subscribe on the server side (will auto-connect if needed)
const success = await changeStreamService.subscribeToBucket(this.bucketName, this.currentPrefix || undefined);
this.isStreamConnected = success;
} catch (error) {
console.warn('[S3Browser] Failed to subscribe to changes:', error);
this.isStreamConnected = false;
@@ -305,7 +312,7 @@ export class TsviewS3Browser extends DeesElement {
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);
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 1000);
this.previewWidth = newWidth;
};

View File

@@ -6,6 +6,25 @@ import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
// FileSystem API types for drag-and-drop folder support
interface FileSystemEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
}
interface FileSystemFileEntry extends FileSystemEntry {
file(successCallback: (file: File) => void, errorCallback?: (error: Error) => void): void;
}
interface FileSystemDirectoryEntry extends FileSystemEntry {
createReader(): FileSystemDirectoryReader;
}
interface FileSystemDirectoryReader {
readEntries(
successCallback: (entries: FileSystemEntry[]) => void,
errorCallback?: (error: Error) => void
): void;
}
interface IColumn {
prefix: string;
objects: IS3Object[];
@@ -43,10 +62,25 @@ export class TsviewS3Columns extends DeesElement {
@state()
private accessor createDialogName: string = '';
@state()
private accessor dragOverColumnIndex: number = -1;
@state()
private accessor dragOverFolderPrefix: string | null = null;
@state()
private accessor uploading: boolean = false;
@state()
private accessor uploadProgress: { current: number; total: number } | null = null;
private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null;
private readonly DEFAULT_COLUMN_WIDTH = 250;
private readonly MIN_COLUMN_WIDTH = 150;
private readonly MAX_COLUMN_WIDTH = 500;
private dragCounters: Map<number, number> = new Map();
private folderHoverTimer: ReturnType<typeof setTimeout> | null = null;
private fileInputElement: HTMLInputElement | null = null;
public static styles = [
cssManager.defaultStyles,
@@ -77,6 +111,7 @@ export class TsviewS3Columns extends DeesElement {
height: 100%;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.resize-handle {
@@ -183,6 +218,58 @@ export class TsviewS3Columns extends DeesElement {
color: #666;
}
.column.drag-over {
background: rgba(59, 130, 246, 0.08);
outline: 2px dashed rgba(59, 130, 246, 0.4);
outline-offset: -2px;
}
.column-item.folder.drag-target {
background: rgba(59, 130, 246, 0.2) !important;
outline: 1px solid rgba(59, 130, 246, 0.5);
}
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
border-radius: 4px;
}
.upload-overlay .upload-text {
color: #e0e0e0;
font-size: 13px;
}
.upload-overlay .upload-progress {
color: #888;
font-size: 11px;
}
.drag-hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
pointer-events: none;
white-space: nowrap;
z-index: 5;
}
.dialog-overlay {
position: fixed;
top: 0;
@@ -288,11 +375,18 @@ export class TsviewS3Columns extends DeesElement {
await this.loadInitialColumn();
}
disconnectedCallback() {
super.disconnectedCallback();
this.clearFolderHover();
this.dragCounters.clear();
if (this.fileInputElement) { this.fileInputElement.remove(); this.fileInputElement = null; }
}
updated(changedProperties: Map<string, unknown>) {
// Only reset columns when bucket changes or refresh is triggered
// Internal folder navigation is handled by selectFolder() which appends columns
if (changedProperties.has('bucketName') || changedProperties.has('refreshKey')) {
if (changedProperties.has('bucketName')) {
this.loadInitialColumn();
} else if (changedProperties.has('refreshKey')) {
this.refreshAllColumns();
}
}
@@ -316,6 +410,20 @@ export class TsviewS3Columns extends DeesElement {
this.loading = false;
}
private async refreshAllColumns() {
const updatedColumns = await Promise.all(
this.columns.map(async (col) => {
try {
const result = await apiService.listObjects(this.bucketName, col.prefix, '/');
return { ...col, objects: result.objects, prefixes: result.prefixes };
} catch {
return col;
}
})
);
this.columns = updatedColumns;
}
private async selectFolder(columnIndex: number, prefix: string) {
// Update selection in current column
this.columns = this.columns.map((col, i) => {
@@ -457,6 +565,11 @@ export class TsviewS3Columns extends DeesElement {
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{
name: 'Upload...',
iconName: 'lucide:upload',
action: async () => this.triggerFileUpload(prefix),
},
{ divider: true },
{
name: 'Delete Folder',
@@ -465,7 +578,12 @@ export class TsviewS3Columns extends DeesElement {
if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, prefix);
if (success) {
await this.loadInitialColumn();
// Remove columns that were inside the deleted folder
this.columns = this.columns.slice(0, columnIndex + 1);
this.columns = this.columns.map((col, i) =>
i === columnIndex ? { ...col, selectedItem: null } : col
);
await this.refreshColumnByPrefix(this.columns[columnIndex].prefix);
}
}
},
@@ -509,7 +627,7 @@ export class TsviewS3Columns extends DeesElement {
if (confirm(`Delete file "${getFileName(key)}"?`)) {
const success = await apiService.deleteObject(this.bucketName, key);
if (success) {
await this.loadInitialColumn();
await this.refreshColumnByPrefix(this.columns[columnIndex].prefix);
}
}
},
@@ -535,6 +653,11 @@ export class TsviewS3Columns extends DeesElement {
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{
name: 'Upload...',
iconName: 'lucide:upload',
action: async () => this.triggerFileUpload(prefix),
},
]);
}
@@ -572,6 +695,209 @@ export class TsviewS3Columns extends DeesElement {
return defaults[ext] || '';
}
// --- File upload helpers ---
private ensureFileInput(): HTMLInputElement {
if (!this.fileInputElement) {
this.fileInputElement = document.createElement('input');
this.fileInputElement.type = 'file';
this.fileInputElement.multiple = true;
(this.fileInputElement as any).webkitdirectory = true; // Enable folder selection
this.fileInputElement.style.display = 'none';
this.shadowRoot!.appendChild(this.fileInputElement);
}
return this.fileInputElement;
}
private triggerFileUpload(targetPrefix: string) {
const input = this.ensureFileInput();
input.value = '';
const handler = async () => {
input.removeEventListener('change', handler);
if (input.files && input.files.length > 0) {
await this.uploadFiles(Array.from(input.files), targetPrefix);
}
};
input.addEventListener('change', handler);
input.click();
}
private async uploadFiles(files: File[], targetPrefix: string) {
this.uploading = true;
this.uploadProgress = { current: 0, total: files.length };
for (let i = 0; i < files.length; i++) {
const file = files[i];
this.uploadProgress = { current: i + 1, total: files.length };
try {
const base64 = await this.readFileAsBase64(file);
// Use webkitRelativePath if available (folder upload), otherwise just filename
const relativePath = (file as any).webkitRelativePath || file.name;
const key = targetPrefix + relativePath;
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
await apiService.putObject(this.bucketName, key, base64, contentType);
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}
this.uploading = false;
this.uploadProgress = null;
await this.refreshColumnByPrefix(targetPrefix);
}
private async uploadFilesWithPaths(
files: { file: File; relativePath: string }[],
targetPrefix: string
) {
this.uploading = true;
this.uploadProgress = { current: 0, total: files.length };
for (let i = 0; i < files.length; i++) {
const { file, relativePath } = files[i];
this.uploadProgress = { current: i + 1, total: files.length };
try {
const base64 = await this.readFileAsBase64(file);
const key = targetPrefix + relativePath;
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
await apiService.putObject(this.bucketName, key, base64, contentType);
} catch (err) {
console.error(`Failed to upload ${relativePath}:`, err);
}
}
this.uploading = false;
this.uploadProgress = null;
await this.refreshColumnByPrefix(targetPrefix);
}
private readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(',')[1] || '');
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
private async refreshColumnByPrefix(prefix: string) {
const columnIndex = this.columns.findIndex(col => col.prefix === prefix);
if (columnIndex === -1) {
await this.refreshAllColumns();
return;
}
try {
const result = await apiService.listObjects(this.bucketName, prefix, '/');
this.columns = this.columns.map((col, i) =>
i === columnIndex ? { ...col, objects: result.objects, prefixes: result.prefixes } : col
);
} catch {
await this.refreshAllColumns();
}
}
// --- Drag-and-drop handlers ---
private handleColumnDragEnter(e: DragEvent, columnIndex: number) {
e.preventDefault();
e.stopPropagation();
const count = (this.dragCounters.get(columnIndex) || 0) + 1;
this.dragCounters.set(columnIndex, count);
this.dragOverColumnIndex = columnIndex;
}
private handleColumnDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
private handleColumnDragLeave(e: DragEvent, columnIndex: number) {
e.stopPropagation();
const count = (this.dragCounters.get(columnIndex) || 1) - 1;
this.dragCounters.set(columnIndex, count);
if (count <= 0) {
this.dragCounters.delete(columnIndex);
if (this.dragOverColumnIndex === columnIndex) this.dragOverColumnIndex = -1;
this.clearFolderHover();
}
}
private async handleColumnDrop(e: DragEvent, columnIndex: number) {
e.preventDefault();
e.stopPropagation();
this.dragCounters.clear();
this.dragOverColumnIndex = -1;
const items = e.dataTransfer?.items;
if (!items || items.length === 0) return;
const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix;
this.clearFolderHover();
// Collect all files (including from nested folders)
const allFiles: { file: File; relativePath: string }[] = [];
const processEntry = async (entry: FileSystemEntry, path: string): Promise<void> => {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject);
});
allFiles.push({ file, relativePath: path + file.name });
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const reader = dirEntry.createReader();
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
for (const childEntry of entries) {
await processEntry(childEntry, path + entry.name + '/');
}
}
};
// Process all dropped items
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = (item as any).webkitGetAsEntry();
if (entry) {
await processEntry(entry, '');
}
}
}
if (allFiles.length > 0) {
await this.uploadFilesWithPaths(allFiles, targetPrefix);
}
}
private handleFolderDragEnter(e: DragEvent, folderPrefix: string) {
e.stopPropagation();
this.clearFolderHover();
this.folderHoverTimer = setTimeout(() => {
this.dragOverFolderPrefix = folderPrefix;
}, 500);
}
private handleFolderDragLeave(e: DragEvent, folderPrefix: string) {
e.stopPropagation();
if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null;
if (this.folderHoverTimer) {
clearTimeout(this.folderHoverTimer);
this.folderHoverTimer = null;
}
}
private clearFolderHover() {
if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; }
this.dragOverFolderPrefix = null;
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
@@ -598,7 +924,7 @@ export class TsviewS3Columns extends DeesElement {
if (success) {
this.showCreateDialog = false;
await this.loadInitialColumn();
await this.refreshColumnByPrefix(this.createDialogPrefix);
}
}
@@ -675,7 +1001,14 @@ export class TsviewS3Columns extends DeesElement {
: this.bucketName;
return html`
<div class="column" style="width: ${column.width}px">
<div
class="column ${this.dragOverColumnIndex === index ? 'drag-over' : ''}"
style="width: ${column.width}px"
@dragenter=${(e: DragEvent) => this.handleColumnDragEnter(e, index)}
@dragover=${(e: DragEvent) => this.handleColumnDragOver(e)}
@dragleave=${(e: DragEvent) => this.handleColumnDragLeave(e, index)}
@drop=${(e: DragEvent) => this.handleColumnDrop(e, index)}
>
<div class="column-header" title=${column.prefix || this.bucketName}>
${headerName}
</div>
@@ -686,9 +1019,11 @@ export class TsviewS3Columns extends DeesElement {
${column.prefixes.map(
(prefix) => html`
<div
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''} ${this.dragOverFolderPrefix === prefix ? 'drag-target' : ''}"
@click=${() => this.selectFolder(index, prefix)}
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
@dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)}
@dragleave=${(e: DragEvent) => this.handleFolderDragLeave(e, 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" />
@@ -715,6 +1050,21 @@ export class TsviewS3Columns extends DeesElement {
`
)}
</div>
${this.dragOverColumnIndex === index ? html`
<div class="drag-hint">
${this.dragOverFolderPrefix
? `Drop to upload into ${getFileName(this.dragOverFolderPrefix)}`
: 'Drop to upload here'}
</div>
` : ''}
${this.uploading ? html`
<div class="upload-overlay">
<div class="upload-text">Uploading...</div>
${this.uploadProgress ? html`
<div class="upload-progress">${this.uploadProgress.current} / ${this.uploadProgress.total} files</div>
` : ''}
</div>
` : ''}
</div>
`;
}

View File

@@ -28,6 +28,9 @@ export class TsviewS3Preview extends DeesElement {
@state()
private accessor hasChanges: boolean = false;
@state()
private accessor editing: boolean = false;
@state()
private accessor contentType: string = '';
@@ -83,8 +86,12 @@ export class TsviewS3Preview extends DeesElement {
.preview-content {
flex: 1;
overflow: auto;
padding: 12px;
overflow: hidden;
}
.preview-content dees-preview {
width: 100%;
height: 100%;
}
.preview-content.code-editor {
@@ -96,25 +103,6 @@ export class TsviewS3Preview extends DeesElement {
height: 100%;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
}
.preview-text {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
color: #ccc;
background: rgba(0, 0, 0, 0.3);
padding: 12px;
border-radius: 6px;
}
.preview-actions {
padding: 12px;
border-top: 1px solid #333;
@@ -225,11 +213,6 @@ export class TsviewS3Preview extends DeesElement {
text-align: center;
}
.binary-preview {
text-align: center;
color: #888;
padding: 24px;
}
`,
];
@@ -243,6 +226,7 @@ export class TsviewS3Preview extends DeesElement {
this.error = '';
this.originalTextContent = '';
this.hasChanges = false;
this.editing = false;
}
}
}
@@ -253,6 +237,7 @@ export class TsviewS3Preview extends DeesElement {
this.loading = true;
this.error = '';
this.hasChanges = false;
this.editing = false;
try {
const result = await apiService.getObject(this.bucketName, this.objectKey);
@@ -395,12 +380,17 @@ export class TsviewS3Preview extends DeesElement {
this.hasChanges = newValue !== this.originalTextContent;
}
private handleEdit() {
this.editing = true;
}
private handleDiscard() {
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
if (codeEditor) {
codeEditor.value = this.originalTextContent;
}
this.hasChanges = false;
this.editing = false;
}
private async handleSave() {
@@ -428,6 +418,7 @@ export class TsviewS3Preview extends DeesElement {
if (success) {
this.originalTextContent = currentContent;
this.hasChanges = false;
this.editing = false;
// Update the stored content as well
this.content = base64Content;
}
@@ -486,40 +477,60 @@ export class TsviewS3Preview extends DeesElement {
</div>
</div>
<div class="preview-content ${this.isText() ? 'code-editor' : ''}">
${this.isImage()
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
<div class="preview-content ${this.editing ? 'code-editor' : ''}">
${this.editing
? html`
<dees-input-code
.value=${this.originalTextContent}
.language=${this.getLanguage()}
height="100%"
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
></dees-input-code>
`
: this.isText()
? html`
<dees-input-code
.value=${this.originalTextContent}
<dees-preview
.textContent=${this.originalTextContent}
.filename=${getFileName(this.objectKey)}
.language=${this.getLanguage()}
height="100%"
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
></dees-input-code>
.showToolbar=${true}
.showFilename=${false}
></dees-preview>
`
: html`
<div class="binary-preview">
<p>Binary file preview not available</p>
<p>Download to view</p>
</div>
`}
<dees-preview
.base64=${this.content}
.mimeType=${this.contentType}
.filename=${getFileName(this.objectKey)}
.showToolbar=${true}
.showFilename=${false}
></dees-preview>
`
}
</div>
<div class="preview-actions">
${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>
`}
${this.editing
? html`
<button class="action-btn secondary" @click=${this.handleDiscard}>
${this.hasChanges ? 'Discard' : 'Cancel'}
</button>
<button
class="action-btn primary"
@click=${this.handleSave}
?disabled=${this.saving || !this.hasChanges}
>
${this.saving ? 'Saving...' : 'Save'}
</button>
`
: html`
${this.isText()
? html`<button class="action-btn" @click=${this.handleEdit}>Edit</button>`
: ''}
<button class="action-btn" @click=${this.handleDownload}>Download</button>
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
`
}
</div>
</div>
`;

View File

@@ -60,8 +60,13 @@ export class ChangeStreamService {
private typedSocket: plugins.typedsocket.TypedSocket | null = null;
private isConnected = false;
private isConnecting = false;
private connectPromise: Promise<void> | null = null;
private subscriptions: Map<string, ISubscription> = new Map();
// Buffer activity events so they survive tab switches
private activityBuffer: IActivityEvent[] = [];
private static readonly ACTIVITY_BUFFER_SIZE = 500;
// RxJS Subjects for UI components
public readonly mongoChanges$ = new plugins.smartrx.rxjs.Subject<IMongoChangeEvent>();
public readonly s3Changes$ = new plugins.smartrx.rxjs.Subject<IS3ChangeEvent>();
@@ -74,48 +79,75 @@ export class ChangeStreamService {
}
/**
* Connect to the WebSocket server
* Connect to the WebSocket server.
* If a connection is already in progress, waits for it to complete.
*/
public async connect(): Promise<void> {
if (this.isConnected || this.isConnecting) {
if (this.isConnected) {
return;
}
// If already connecting, wait for the existing attempt to finish
if (this.isConnecting && this.connectPromise) {
return this.connectPromise;
}
this.isConnecting = true;
this.connectionStatus$.next('connecting');
this.connectPromise = (async () => {
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.connectPromise = null;
this.connectionStatus$.next('disconnected');
console.error('[ChangeStream] Failed to connect:', error);
throw error;
}
})();
return this.connectPromise;
}
/**
* Ensure a WebSocket connection is established.
* If not connected, triggers connect() and returns whether connection succeeded.
*/
private async ensureConnected(): Promise<boolean> {
if (this.isConnected && this.typedSocket) {
return true;
}
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;
await this.connect();
return this.isConnected;
} catch {
return false;
}
}
@@ -135,6 +167,7 @@ export class ChangeStreamService {
this.typedSocket = null;
this.isConnected = false;
this.connectPromise = null;
this.subscriptions.clear();
this.connectionStatus$.next('disconnected');
@@ -172,6 +205,10 @@ export class ChangeStreamService {
new plugins.typedrequest.TypedHandler<any>(
'pushActivityEvent',
async (data: { event: IActivityEvent }) => {
this.activityBuffer.push(data.event);
if (this.activityBuffer.length > ChangeStreamService.ACTIVITY_BUFFER_SIZE) {
this.activityBuffer = this.activityBuffer.slice(-ChangeStreamService.ACTIVITY_BUFFER_SIZE);
}
this.activityEvents$.next(data.event);
return { received: true };
}
@@ -228,8 +265,11 @@ export class ChangeStreamService {
*/
public async subscribeToCollection(database: string, collection: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
const connected = await this.ensureConnected();
if (!connected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
}
const key = `${database}/${collection}`;
@@ -307,8 +347,11 @@ export class ChangeStreamService {
*/
public async subscribeToBucket(bucket: string, prefix?: string): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
const connected = await this.ensureConnected();
if (!connected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
}
const key = prefix ? `${bucket}/${prefix}` : bucket;
@@ -387,8 +430,11 @@ export class ChangeStreamService {
*/
public async subscribeToActivity(): Promise<boolean> {
if (!this.typedSocket || !this.isConnected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
const connected = await this.ensureConnected();
if (!connected) {
console.warn('[ChangeStream] Not connected, cannot subscribe');
return false;
}
}
// Check if already subscribed
@@ -450,7 +496,10 @@ export class ChangeStreamService {
*/
public async getRecentActivity(limit: number = 100): Promise<IActivityEvent[]> {
if (!this.typedSocket || !this.isConnected) {
return [];
const connected = await this.ensureConnected();
if (!connected) {
return [];
}
}
try {
@@ -470,6 +519,13 @@ export class ChangeStreamService {
return this.subscriptions.has('activity:activity');
}
/**
* Get buffered activity events (captured regardless of UI subscriber)
*/
public getBufferedActivity(): IActivityEvent[] {
return [...this.activityBuffer];
}
// ===========================================
// Observables for UI Components
// ===========================================