Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c5cea7e0b | |||
| d9fc7f8257 | |||
| b41adc184e | |||
| 175e9cb691 | |||
| fbd6ac83f8 | |||
| ebce6b7e76 | |||
| b30e2925aa | |||
| e379c2b6b1 | |||
| 4603154408 | |||
| 5051e957ec | |||
| 319ee2a7af | |||
| 8204f06a2a | |||
| be71629d34 | |||
| 8cc9a1850a | |||
| ad8529cb0f | |||
| 7cef6f89d9 | |||
| 81d7ff0722 | |||
| 856f13f2ad | |||
| f7cd43933f | |||
| 4269058ab5 | |||
| 321e3e89a4 |
66
.gitea/workflows/default_nottags.yaml
Normal 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
|
||||
124
.gitea/workflows/default_tags.yaml
Normal 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
@@ -20,4 +20,4 @@ dist_*/
|
||||
.claude/
|
||||
.serena/
|
||||
|
||||
#------# custom
|
||||
#------# custom
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 43 KiB |
11
.vscode/launch.json
vendored
Normal 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
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
101
changelog.md
@@ -1,6 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-01-28 - 1.11.0 - feat(s3)
|
||||
add rename support for files and folders in S3 UI columns and keys
|
||||
|
||||
- Adds 'Rename' option to context menus in tsview-s3-columns and tsview-s3-keys for files and folders
|
||||
- Implements a rename dialog with validation, auto-focus and smart selection (preserves extensions), and progress/error states
|
||||
- Performs renames via apiService.moveObject / apiService.movePrefix and refreshes the UI after success
|
||||
- Bumps @design.estate/dees-catalog dependency to ^3.41.2
|
||||
|
||||
## 2026-01-28 - 1.10.2 - fix(tsview-s3-columns)
|
||||
append trailing slash to destination key when moving folders to ensure folder destination path
|
||||
|
||||
- ts_web/elements/tsview-s3-columns.ts: add '/' to destKey when moveSource.isFolder to preserve folder semantics
|
||||
- Prevents folder moves from being treated as object moves by ensuring destination key ends with a slash
|
||||
- Change affects logic that calls apiService.movePrefix for folder moves
|
||||
|
||||
## 2026-01-28 - 1.10.1 - fix(playwright-mcp)
|
||||
remove Playwright-generated snapshot images to avoid committing autogenerated test artifacts and reduce repository size
|
||||
|
||||
- Deleted ~22 Playwright PNG snapshot files under .playwright-mcp (visual test artifacts).
|
||||
- No source code logic changed — this is test artifact cleanup only.
|
||||
- Reduces repository bloat and prevents noisy visual diffs in future Playwright runs.
|
||||
|
||||
## 2026-01-28 - 1.10.0 - feat(s3)
|
||||
add S3 move (object & prefix) support: server handlers, API client methods, UI dialogs/picker, drag-and-drop and validation
|
||||
|
||||
- Server: added typed handlers for moveObject (copy+delete for a single object) and movePrefix (recursive list, copy all objects to dest then delete source directory). Handlers return success/error and movedCount for prefix moves.
|
||||
- API: added api.service methods moveObject and movePrefix so web client can call new server handlers.
|
||||
- Interfaces: introduced IReq_MoveObject and IReq_MovePrefix request/response typings in ts/interfaces.
|
||||
- Web UI: added move dialogs, a move picker, drag-and-drop support for folders/files, UI states and styles in ts_web elements (tsview-s3-columns, tsview-s3-keys). Move dialogs/picker integrated into existing render flows.
|
||||
- Utilities: added move-validator utility (validateMove, getParentPrefix) and exported it from utilities index to prevent invalid operations (e.g. moving a folder into itself or to the same parent).
|
||||
- Behavior notes: prefix move implementation performs recursive listing, copies each object to the new prefix, then deletes the source directory (permanent). Errors are caught and surfaced; movedCount is returned for prefix moves.
|
||||
|
||||
## 2026-01-28 - 1.9.0 - feat(s3-columns)
|
||||
load full prefix path on initial load and add folder upload support
|
||||
|
||||
- loadInitialColumn now loads all prefix path segments in parallel and pre-selects child prefixes so multi-column path is restored
|
||||
- added getPathSegments helper and auto-scroll to show the rightmost column after load
|
||||
- added separate hidden folder input (webkitdirectory) and folder upload flow; triggerFileUpload now accepts 'files' | 'folder'
|
||||
- replaced generic 'Upload...' with 'Upload Files...' and added 'Upload Folder...' menu items
|
||||
- updated updated() to react to currentPrefix changes and cleaned up folder input on disconnectedCallback
|
||||
|
||||
## 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 +102,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 +115,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 +126,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 +136,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 +169,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 +197,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)
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@git.zone/tsview",
|
||||
"version": "1.5.0",
|
||||
"version": "1.11.0",
|
||||
"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.2",
|
||||
"@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": {}
|
||||
}
|
||||
}
|
||||
|
||||
172
pnpm-lock.yaml
generated
@@ -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.2
|
||||
version: 3.41.2(@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.2':
|
||||
resolution: {integrity: sha512-G6b7TbqkEupHwty3q+Y42xbmwkfFBf3S5JBrMLAw1S0kR88ZCio0dOBcvmvQTQ5pQBz6TDRkx1prXpoQbZDt7A==}
|
||||
|
||||
'@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':
|
||||
@@ -660,74 +660,74 @@ packages:
|
||||
'@mongodb-js/saslprep@1.4.5':
|
||||
resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.88':
|
||||
resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==}
|
||||
'@napi-rs/canvas-android-arm64@0.1.89':
|
||||
resolution: {integrity: sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.88':
|
||||
resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==}
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.89':
|
||||
resolution: {integrity: sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.88':
|
||||
resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==}
|
||||
'@napi-rs/canvas-darwin-x64@0.1.89':
|
||||
resolution: {integrity: sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
|
||||
resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==}
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.89':
|
||||
resolution: {integrity: sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
|
||||
resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==}
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.89':
|
||||
resolution: {integrity: sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
|
||||
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.89':
|
||||
resolution: {integrity: sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
|
||||
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.89':
|
||||
resolution: {integrity: sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
|
||||
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.89':
|
||||
resolution: {integrity: sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.88':
|
||||
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.89':
|
||||
resolution: {integrity: sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
|
||||
resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.89':
|
||||
resolution: {integrity: sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
|
||||
resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==}
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.89':
|
||||
resolution: {integrity: sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.88':
|
||||
resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==}
|
||||
'@napi-rs/canvas@0.1.89':
|
||||
resolution: {integrity: sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.0.7':
|
||||
@@ -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.2(@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.2(@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
|
||||
@@ -5203,52 +5203,52 @@ snapshots:
|
||||
dependencies:
|
||||
sparse-bitfield: 3.0.3
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.88':
|
||||
'@napi-rs/canvas-android-arm64@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.88':
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.88':
|
||||
'@napi-rs/canvas-darwin-x64@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.88':
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.89':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.88':
|
||||
'@napi-rs/canvas@0.1.89':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.88
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.88
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.88
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.88
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.88
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.88
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.88
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.88
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.88
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.88
|
||||
'@napi-rs/canvas-android-arm64': 0.1.89
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.89
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.89
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.89
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.89
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.89
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.89
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.89
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.89
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.89
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.89
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.0.7':
|
||||
@@ -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
|
||||
@@ -8747,7 +8747,7 @@ snapshots:
|
||||
|
||||
pdfjs-dist@4.10.38:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.88
|
||||
'@napi-rs/canvas': 0.1.89
|
||||
|
||||
peek-readable@5.4.2: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsview',
|
||||
version: '1.5.0',
|
||||
version: '1.11.0',
|
||||
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -465,4 +465,131 @@ export async function registerS3Handlers(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Move object (copy + delete)
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.IReq_MoveObject>(
|
||||
'moveObject',
|
||||
async (reqData) => {
|
||||
const smartbucket = await tsview.getSmartBucket();
|
||||
if (!smartbucket) {
|
||||
return { success: false, error: 'S3 not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||
if (!bucket) {
|
||||
return { success: false, error: `Bucket ${reqData.bucketName} not found` };
|
||||
}
|
||||
|
||||
// Read source content
|
||||
const content = await bucket.fastGet({ path: reqData.sourceKey });
|
||||
|
||||
// Write to destination
|
||||
await bucket.fastPut({
|
||||
path: reqData.destKey,
|
||||
contents: content,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
// Delete source
|
||||
await bucket.fastRemove({ path: reqData.sourceKey });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error moving object:', err);
|
||||
return { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Move prefix (folder) - copy all objects then delete all
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.IReq_MovePrefix>(
|
||||
'movePrefix',
|
||||
async (reqData) => {
|
||||
const smartbucket = await tsview.getSmartBucket();
|
||||
if (!smartbucket) {
|
||||
return { success: false, movedCount: 0, error: 'S3 not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||
if (!bucket) {
|
||||
return { success: false, movedCount: 0, error: `Bucket ${reqData.bucketName} not found` };
|
||||
}
|
||||
|
||||
// List all objects under the source prefix recursively
|
||||
const allObjects: string[] = [];
|
||||
|
||||
const listRecursive = async (prefix: string): Promise<void> => {
|
||||
const baseDir = await bucket.getBaseDirectory();
|
||||
let targetDir = baseDir;
|
||||
|
||||
if (prefix) {
|
||||
const prefixParts = prefix.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
for (const part of prefixParts) {
|
||||
const subDir = await targetDir.getSubDirectoryByName(part, { getEmptyDirectory: true });
|
||||
if (subDir) {
|
||||
targetDir = subDir;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get files in this directory
|
||||
const files = await targetDir.listFiles();
|
||||
for (const file of files) {
|
||||
allObjects.push(prefix + file.name);
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
const dirs = await targetDir.listDirectories();
|
||||
for (const dir of dirs) {
|
||||
await listRecursive(prefix + dir.name + '/');
|
||||
}
|
||||
};
|
||||
|
||||
await listRecursive(reqData.sourcePrefix);
|
||||
|
||||
// Copy all objects to new location
|
||||
for (const objKey of allObjects) {
|
||||
const relativePath = objKey.substring(reqData.sourcePrefix.length);
|
||||
const newKey = reqData.destPrefix + relativePath;
|
||||
|
||||
const content = await bucket.fastGet({ path: objKey });
|
||||
await bucket.fastPut({
|
||||
path: newKey,
|
||||
contents: content,
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the source directory
|
||||
const baseDir = await bucket.getBaseDirectory();
|
||||
let targetDir = baseDir;
|
||||
const prefix = reqData.sourcePrefix.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, movedCount: 0, error: 'Source folder not found' };
|
||||
}
|
||||
}
|
||||
|
||||
await targetDir.delete({ mode: 'permanent' });
|
||||
|
||||
return { success: true, movedCount: allObjects.length };
|
||||
} catch (err) {
|
||||
console.error('Error moving prefix:', err);
|
||||
return { success: false, movedCount: 0, error: String(err) };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,6 +213,39 @@ export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implem
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_MoveObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_MoveObject
|
||||
> {
|
||||
method: 'moveObject';
|
||||
request: {
|
||||
bucketName: string;
|
||||
sourceKey: string;
|
||||
destKey: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_MovePrefix extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_MovePrefix
|
||||
> {
|
||||
method: 'movePrefix';
|
||||
request: {
|
||||
bucketName: string;
|
||||
sourcePrefix: string;
|
||||
destPrefix: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
movedCount: number;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetObjectUrl
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsview',
|
||||
version: '1.5.0',
|
||||
version: '1.11.0',
|
||||
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IS3Object } from '../services/index.js';
|
||||
import { formatSize, getFileName } from '../utilities/index.js';
|
||||
import { formatSize, getFileName, validateMove, getParentPrefix } from '../utilities/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
@@ -44,6 +44,54 @@ export class TsviewS3Keys extends DeesElement {
|
||||
@state()
|
||||
private accessor createDialogName: string = '';
|
||||
|
||||
// Move dialog state
|
||||
@state()
|
||||
private accessor showMoveDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor moveSource: { key: string; isFolder: boolean } | null = null;
|
||||
|
||||
@state()
|
||||
private accessor moveDestination: string = '';
|
||||
|
||||
@state()
|
||||
private accessor moveInProgress: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor moveError: string | null = null;
|
||||
|
||||
// Move picker dialog state
|
||||
@state()
|
||||
private accessor showMovePickerDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor movePickerSource: { key: string; isFolder: boolean } | null = null;
|
||||
|
||||
@state()
|
||||
private accessor movePickerCurrentPrefix: string = '';
|
||||
|
||||
@state()
|
||||
private accessor movePickerPrefixes: string[] = [];
|
||||
|
||||
@state()
|
||||
private accessor movePickerLoading: boolean = false;
|
||||
|
||||
// Rename dialog state
|
||||
@state()
|
||||
private accessor showRenameDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor renameSource: { key: string; isFolder: boolean } | null = null;
|
||||
|
||||
@state()
|
||||
private accessor renameName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor renameInProgress: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor renameError: string | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
themeStyles,
|
||||
@@ -256,6 +304,120 @@ export class TsviewS3Keys extends DeesElement {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Move dialog styles */
|
||||
.move-summary {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.move-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.move-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.move-label {
|
||||
color: #888;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.move-path {
|
||||
color: #e0e0e0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.move-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Move picker dialog styles */
|
||||
.move-picker-dialog {
|
||||
min-width: 450px;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.picker-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.picker-crumb {
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-crumb:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.picker-separator {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.picker-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.picker-item .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-item .name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -340,6 +502,17 @@ export class TsviewS3Keys extends DeesElement {
|
||||
await navigator.clipboard.writeText(key);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Rename',
|
||||
iconName: 'lucide:pencil',
|
||||
action: async () => this.openRenameDialog(key, true),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Move to...',
|
||||
iconName: 'lucide:folderInput',
|
||||
action: async () => this.openMovePickerDialog(key, true),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'New Folder Inside',
|
||||
@@ -392,6 +565,17 @@ export class TsviewS3Keys extends DeesElement {
|
||||
await navigator.clipboard.writeText(key);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Rename',
|
||||
iconName: 'lucide:pencil',
|
||||
action: async () => this.openRenameDialog(key, false),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Move to...',
|
||||
iconName: 'lucide:folderInput',
|
||||
action: async () => this.openMovePickerDialog(key, false),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
@@ -533,6 +717,346 @@ export class TsviewS3Keys extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Helper for path segments ---
|
||||
|
||||
private getPathSegments(prefix: string): string[] {
|
||||
if (!prefix) return [];
|
||||
const parts = prefix.split('/').filter(p => p);
|
||||
const segments: string[] = [];
|
||||
let cumulative = '';
|
||||
for (const part of parts) {
|
||||
cumulative += part + '/';
|
||||
segments.push(cumulative);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// --- Move picker dialog ---
|
||||
|
||||
private async openMovePickerDialog(key: string, isFolder: boolean) {
|
||||
this.movePickerSource = { key, isFolder };
|
||||
this.movePickerCurrentPrefix = '';
|
||||
this.showMovePickerDialog = true;
|
||||
await this.loadMovePickerPrefixes('');
|
||||
}
|
||||
|
||||
private async navigateMovePicker(prefix: string) {
|
||||
this.movePickerCurrentPrefix = prefix;
|
||||
await this.loadMovePickerPrefixes(prefix);
|
||||
}
|
||||
|
||||
private async loadMovePickerPrefixes(prefix: string) {
|
||||
this.movePickerLoading = true;
|
||||
try {
|
||||
const result = await apiService.listObjects(this.bucketName, prefix, '/');
|
||||
this.movePickerPrefixes = result.prefixes;
|
||||
} catch {
|
||||
this.movePickerPrefixes = [];
|
||||
}
|
||||
this.movePickerLoading = false;
|
||||
}
|
||||
|
||||
private selectMoveDestination(destPrefix: string) {
|
||||
if (!this.movePickerSource) return;
|
||||
this.closeMovePickerDialog();
|
||||
this.initiateMove(this.movePickerSource.key, this.movePickerSource.isFolder, destPrefix);
|
||||
}
|
||||
|
||||
private closeMovePickerDialog() {
|
||||
this.showMovePickerDialog = false;
|
||||
this.movePickerSource = null;
|
||||
this.movePickerCurrentPrefix = '';
|
||||
this.movePickerPrefixes = [];
|
||||
}
|
||||
|
||||
// --- Move confirmation dialog ---
|
||||
|
||||
private initiateMove(sourceKey: string, isFolder: boolean, destPrefix: string) {
|
||||
const validation = validateMove(sourceKey, destPrefix);
|
||||
|
||||
if (!validation.valid) {
|
||||
this.moveSource = { key: sourceKey, isFolder };
|
||||
this.moveDestination = destPrefix;
|
||||
this.moveError = validation.error!;
|
||||
this.showMoveDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.moveSource = { key: sourceKey, isFolder };
|
||||
this.moveDestination = destPrefix;
|
||||
this.moveError = null;
|
||||
this.showMoveDialog = true;
|
||||
}
|
||||
|
||||
private async executeMove() {
|
||||
if (!this.moveSource) return;
|
||||
|
||||
this.moveInProgress = true;
|
||||
|
||||
try {
|
||||
const sourceName = getFileName(this.moveSource.key);
|
||||
const destKey = this.moveDestination + sourceName;
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
if (this.moveSource.isFolder) {
|
||||
result = await apiService.movePrefix(
|
||||
this.bucketName,
|
||||
this.moveSource.key,
|
||||
destKey
|
||||
);
|
||||
} else {
|
||||
result = await apiService.moveObject(
|
||||
this.bucketName,
|
||||
this.moveSource.key,
|
||||
destKey
|
||||
);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.closeMoveDialog();
|
||||
await this.loadObjects();
|
||||
} else {
|
||||
this.moveError = result.error || 'Move operation failed';
|
||||
}
|
||||
} catch (err) {
|
||||
this.moveError = `Error: ${err}`;
|
||||
}
|
||||
|
||||
this.moveInProgress = false;
|
||||
}
|
||||
|
||||
private closeMoveDialog() {
|
||||
this.showMoveDialog = false;
|
||||
this.moveSource = null;
|
||||
this.moveDestination = '';
|
||||
this.moveError = null;
|
||||
this.moveInProgress = false;
|
||||
}
|
||||
|
||||
// --- Rename dialog methods ---
|
||||
|
||||
private openRenameDialog(key: string, isFolder: boolean) {
|
||||
this.renameSource = { key, isFolder };
|
||||
this.renameName = getFileName(key);
|
||||
this.renameError = null;
|
||||
this.showRenameDialog = true;
|
||||
|
||||
// Auto-focus and smart selection
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.shadowRoot?.querySelector('.rename-dialog-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
if (!isFolder) {
|
||||
const lastDot = this.renameName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
input.setSelectionRange(0, lastDot);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async executeRename() {
|
||||
if (!this.renameSource || !this.renameName.trim()) return;
|
||||
|
||||
const newName = this.renameName.trim();
|
||||
const currentName = getFileName(this.renameSource.key);
|
||||
|
||||
if (newName === currentName) {
|
||||
this.renameError = 'Name is the same as current';
|
||||
return;
|
||||
}
|
||||
if (!newName) {
|
||||
this.renameError = 'Name cannot be empty';
|
||||
return;
|
||||
}
|
||||
if (newName.includes('/')) {
|
||||
this.renameError = 'Name cannot contain "/"';
|
||||
return;
|
||||
}
|
||||
|
||||
this.renameInProgress = true;
|
||||
this.renameError = null;
|
||||
|
||||
try {
|
||||
const parentPrefix = getParentPrefix(this.renameSource.key);
|
||||
const newKey = parentPrefix + newName + (this.renameSource.isFolder ? '/' : '');
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
if (this.renameSource.isFolder) {
|
||||
result = await apiService.movePrefix(this.bucketName, this.renameSource.key, newKey);
|
||||
} else {
|
||||
result = await apiService.moveObject(this.bucketName, this.renameSource.key, newKey);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.closeRenameDialog();
|
||||
await this.loadObjects();
|
||||
} else {
|
||||
this.renameError = result.error || 'Rename failed';
|
||||
}
|
||||
} catch (err) {
|
||||
this.renameError = `Error: ${err}`;
|
||||
}
|
||||
this.renameInProgress = false;
|
||||
}
|
||||
|
||||
private closeRenameDialog() {
|
||||
this.showRenameDialog = false;
|
||||
this.renameSource = null;
|
||||
this.renameName = '';
|
||||
this.renameError = null;
|
||||
this.renameInProgress = false;
|
||||
}
|
||||
|
||||
private renderRenameDialog() {
|
||||
if (!this.showRenameDialog || !this.renameSource) return '';
|
||||
|
||||
const isFolder = this.renameSource.isFolder;
|
||||
const title = isFolder ? 'Rename Folder' : 'Rename File';
|
||||
|
||||
return html`
|
||||
<div class="dialog-overlay" @click=${() => this.closeRenameDialog()}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">${title}</div>
|
||||
<div class="dialog-location">
|
||||
Location: ${this.bucketName}/${getParentPrefix(this.renameSource.key)}
|
||||
</div>
|
||||
${this.renameError ? html`<div class="move-error">${this.renameError}</div>` : ''}
|
||||
<input
|
||||
type="text"
|
||||
class="dialog-input rename-dialog-input"
|
||||
placeholder=${isFolder ? 'folder-name' : 'filename.ext'}
|
||||
.value=${this.renameName}
|
||||
@input=${(e: InputEvent) => {
|
||||
this.renameName = (e.target as HTMLInputElement).value;
|
||||
this.renameError = null;
|
||||
}}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') this.executeRename();
|
||||
if (e.key === 'Escape') this.closeRenameDialog();
|
||||
}}
|
||||
/>
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn dialog-btn-cancel"
|
||||
@click=${() => this.closeRenameDialog()}
|
||||
?disabled=${this.renameInProgress}>Cancel</button>
|
||||
<button class="dialog-btn dialog-btn-create"
|
||||
@click=${() => this.executeRename()}
|
||||
?disabled=${this.renameInProgress || !this.renameName.trim()}>
|
||||
${this.renameInProgress ? 'Renaming...' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMoveDialog() {
|
||||
if (!this.showMoveDialog || !this.moveSource) return '';
|
||||
|
||||
const sourceName = getFileName(this.moveSource.key);
|
||||
|
||||
return html`
|
||||
<div class="dialog-overlay" @click=${() => this.closeMoveDialog()}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">Move ${this.moveSource.isFolder ? 'Folder' : 'File'}</div>
|
||||
|
||||
${this.moveError ? html`
|
||||
<div class="move-error">${this.moveError}</div>
|
||||
` : html`
|
||||
<div class="move-summary">
|
||||
<div class="move-item">
|
||||
<span class="move-label">From:</span>
|
||||
<span class="move-path">${this.moveSource.key}</span>
|
||||
</div>
|
||||
<div class="move-item">
|
||||
<span class="move-label">To:</span>
|
||||
<span class="move-path">${this.moveDestination}${sourceName}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn dialog-btn-cancel"
|
||||
@click=${() => this.closeMoveDialog()}
|
||||
?disabled=${this.moveInProgress}>
|
||||
Cancel
|
||||
</button>
|
||||
${!this.moveError ? html`
|
||||
<button class="dialog-btn dialog-btn-create"
|
||||
@click=${() => this.executeMove()}
|
||||
?disabled=${this.moveInProgress}>
|
||||
${this.moveInProgress ? 'Moving...' : 'Move'}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMovePickerDialog() {
|
||||
if (!this.showMovePickerDialog || !this.movePickerSource) return '';
|
||||
|
||||
const sourceName = getFileName(this.movePickerSource.key);
|
||||
const sourceParent = getParentPrefix(this.movePickerSource.key);
|
||||
|
||||
return html`
|
||||
<div class="dialog-overlay" @click=${() => this.closeMovePickerDialog()}>
|
||||
<div class="dialog move-picker-dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">Move "${sourceName}" to...</div>
|
||||
|
||||
<div class="picker-breadcrumb">
|
||||
<span class="picker-crumb" @click=${() => this.navigateMovePicker('')}>
|
||||
${this.bucketName}
|
||||
</span>
|
||||
${this.getPathSegments(this.movePickerCurrentPrefix).map(seg => html`
|
||||
<span class="picker-separator">/</span>
|
||||
<span class="picker-crumb" @click=${() => this.navigateMovePicker(seg)}>
|
||||
${getFileName(seg)}
|
||||
</span>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="picker-list">
|
||||
${this.movePickerLoading ? html`<div class="picker-empty">Loading...</div>` : ''}
|
||||
${!this.movePickerLoading && this.movePickerPrefixes.filter(p => p !== this.movePickerSource!.key).length === 0 ? html`
|
||||
<div class="picker-empty">No subfolders</div>
|
||||
` : ''}
|
||||
${this.movePickerPrefixes
|
||||
.filter(p => p !== this.movePickerSource!.key) // Hide source from list
|
||||
.map(prefix => html`
|
||||
<div class="picker-item"
|
||||
@click=${() => this.navigateMovePicker(prefix)}
|
||||
@dblclick=${() => this.selectMoveDestination(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"/>
|
||||
</svg>
|
||||
<span class="name">${getFileName(prefix)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.closeMovePickerDialog()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="dialog-btn dialog-btn-create"
|
||||
@click=${() => this.selectMoveDestination(this.movePickerCurrentPrefix)}
|
||||
?disabled=${this.movePickerCurrentPrefix === sourceParent}>
|
||||
Move Here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="keys-container">
|
||||
@@ -595,6 +1119,9 @@ export class TsviewS3Keys extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
${this.renderCreateDialog()}
|
||||
${this.renderMoveDialog()}
|
||||
${this.renderMovePickerDialog()}
|
||||
${this.renderRenameDialog()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -168,6 +168,28 @@ export class ApiService {
|
||||
return result.success;
|
||||
}
|
||||
|
||||
async moveObject(
|
||||
bucketName: string,
|
||||
sourceKey: string,
|
||||
destKey: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return this.request<
|
||||
{ bucketName: string; sourceKey: string; destKey: string },
|
||||
{ success: boolean; error?: string }
|
||||
>('moveObject', { bucketName, sourceKey, destKey });
|
||||
}
|
||||
|
||||
async movePrefix(
|
||||
bucketName: string,
|
||||
sourcePrefix: string,
|
||||
destPrefix: string
|
||||
): Promise<{ success: boolean; movedCount: number; error?: string }> {
|
||||
return this.request<
|
||||
{ bucketName: string; sourcePrefix: string; destPrefix: string },
|
||||
{ success: boolean; movedCount: number; error?: string }
|
||||
>('movePrefix', { bucketName, sourcePrefix, destPrefix });
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// MongoDB API Methods
|
||||
// ===========================================
|
||||
|
||||
@@ -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
|
||||
// ===========================================
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './formatters.js';
|
||||
export * from './move-validator.js';
|
||||
|
||||
47
ts_web/utilities/move-validator.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Move validation utilities for S3 objects
|
||||
*/
|
||||
|
||||
export interface IMoveValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a move operation is allowed
|
||||
* @param sourceKey The source object key (file or folder with trailing /)
|
||||
* @param destPrefix The destination prefix (folder)
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validateMove(sourceKey: string, destPrefix: string): IMoveValidation {
|
||||
// Check: Moving folder into itself or a subfolder of itself
|
||||
if (sourceKey.endsWith('/')) {
|
||||
// It's a folder - check if destPrefix starts with sourceKey
|
||||
if (destPrefix.startsWith(sourceKey)) {
|
||||
return { valid: false, error: 'Cannot move a folder into itself' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check: Source and dest are the same location
|
||||
const sourceParent = getParentPrefix(sourceKey);
|
||||
if (sourceParent === destPrefix) {
|
||||
return { valid: false, error: 'Item is already in this location' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent prefix (directory) of a given key
|
||||
* @param key The object key (file or folder)
|
||||
* @returns The parent prefix
|
||||
*/
|
||||
export function getParentPrefix(key: string): string {
|
||||
// "folder1/folder2/file.txt" -> "folder1/folder2/"
|
||||
// "folder1/folder2/" -> "folder1/"
|
||||
// "file.txt" -> ""
|
||||
// "folder/" -> ""
|
||||
const trimmed = key.endsWith('/') ? key.slice(0, -1) : key;
|
||||
const lastSlash = trimmed.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? trimmed.substring(0, lastSlash + 1) : '';
|
||||
}
|
||||