23 Commits

Author SHA1 Message Date
219a34e6a5 v1.12.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:02:45 +00:00
69ab8f3436 fix(config): migrate project config to .smartconfig.json and refresh build settings 2026-03-24 19:02:45 +00:00
1b5b023556 fix(build): update bundled_ui.ts with latest build output 2026-03-24 15:09:16 +00:00
61a83f0c03 v1.12.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 15:08:40 +00:00
a9448ec0df fix(package): bump package version to 1.12.3 2026-03-24 15:08:40 +00:00
a29f13c75a v1.12.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 15:08:13 +00:00
02b3a79d99 fix(config): migrate runtime configuration loading from npmextra to smartconfig 2026-03-24 15:08:13 +00:00
7f4528bdab v1.12.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-14 23:27:25 +00:00
a829f76d4b fix(storage): rename S3 configuration and change stream interfaces to storage-oriented types 2026-03-14 23:27:25 +00:00
2da2d57df1 v1.12.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 15:16:24 +00:00
150d6d9d86 feat(web): replace custom S3 browser components with dees-s3-browser integration 2026-03-12 15:16:24 +00:00
c4afbdfd7f v1.11.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 13m25s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-29 15:56:05 +00:00
b72b36c4ea fix(mongo-browser): increase default editor width and broaden resize range in Mongo browser pane 2026-01-29 15:56:05 +00:00
8c5cea7e0b v1.11.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 14m55s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 16:49:34 +00:00
d9fc7f8257 feat(s3): add rename support for files and folders in S3 UI columns and keys 2026-01-28 16:49:34 +00:00
b41adc184e v1.10.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 13m2s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 16:16:26 +00:00
175e9cb691 fix(tsview-s3-columns): append trailing slash to destination key when moving folders to ensure folder destination path 2026-01-28 16:16:26 +00:00
fbd6ac83f8 v1.10.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 11m25s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 15:38:04 +00:00
ebce6b7e76 fix(playwright-mcp): remove Playwright-generated snapshot images to avoid committing autogenerated test artifacts and reduce repository size 2026-01-28 15:38:04 +00:00
b30e2925aa v1.10.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 14m1s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 15:35:28 +00:00
e379c2b6b1 feat(s3): add S3 move (object & prefix) support: server handlers, API client methods, UI dialogs/picker, drag-and-drop and validation 2026-01-28 15:35:28 +00:00
4603154408 v1.9.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 14m44s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-01-28 15:04:42 +00:00
5051e957ec feat(s3-columns): load full prefix path on initial load and add folder upload support 2026-01-28 15:04:42 +00:00
55 changed files with 3279 additions and 5674 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -34,7 +34,7 @@
"@git.zone/cli": { "@git.zone/cli": {
"services": ["mongodb", "minio"], "services": ["mongodb", "minio"],
"release": { "release": {
"registries": ["https://verdaccio.lossless.digital"], "registries": ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"],
"accessLevel": "public" "accessLevel": "public"
}, },
"projectType": "npm", "projectType": "npm",

View File

@@ -1,7 +1,7 @@
{ {
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["/npmextra.json"], "fileMatch": ["/.smartconfig.json"],
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1,5 +1,88 @@
# Changelog # Changelog
## 2026-03-24 - 1.12.4 - fix(config)
migrate project config to .smartconfig.json and refresh build settings
- replaces npmextra.json with .smartconfig.json and updates packaged files accordingly
- adds --skiplibcheck to the build script and simplifies tsconfig settings
- bumps build and runtime dependencies and updates README references to the new config file
## 2026-03-24 - 1.12.3 - fix(package)
bump package version to 1.12.3
- Updates the package version from 1.12.2 to 1.12.3.
## 2026-03-24 - 1.12.2 - fix(config)
migrate runtime configuration loading from npmextra to smartconfig
- replace the @push.rocks/npmextra dependency with @push.rocks/smartconfig
- update tsview startup configuration types and loading logic to read port, killIfBusy, and openBrowser from smartconfig
- adjust busy-port error messaging to reference smartconfig.json
- add npmjs.org to the release registries configuration
## 2026-03-14 - 1.12.1 - fix(storage)
rename S3 configuration and change stream interfaces to storage-oriented types
- Renames public config APIs from setS3Config/getS3Config/hasS3 to setStorageConfig/getStorageConfig/hasStorage.
- Replaces shared S3 interfaces with storage equivalents such as IStorageConfig, IStorageObject, IStorageChangeEvent, and storage subscription request types.
- Updates frontend integration to use dees-storage-browser and the new storage data provider and change stream types.
- Refreshes dependency versions and documentation to match the new storage-oriented naming.
## 2026-03-12 - 1.12.0 - feat(web)
replace custom S3 browser components with dees-s3-browser integration
- adds an S3 data provider adapter that delegates object operations to the existing ApiService
- wires live bucket change events into dees-s3-browser via changeStreamService
- removes exported custom tsview S3 elements in favor of the shared catalog component
## 2026-01-29 - 1.11.1 - fix(mongo-browser)
increase default editor width and broaden resize range in Mongo browser pane
- Set default editorWidth from 400 to 700
- Update CSS grid-template default editor width variable to 700
- Expand editor resize bounds: min 300 -> 250, max 700 -> 1000 (resizer calculation adjusted)
## 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) ## 2026-01-28 - 1.8.1 - fix(cli)
set executable permission on cli.js set executable permission on cli.js

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tsview", "name": "@git.zone/tsview",
"version": "1.8.1", "version": "1.12.4",
"private": false, "private": false,
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI", "description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "pnpm run build && tstest test/ --verbose", "test": "pnpm run build && tstest test/ --verbose",
"build": "pnpm run bundle && tsbuild --allowimplicitany", "build": "pnpm run bundle && tsbuild --allowimplicitany --skiplibcheck",
"bundle": "tsbundle", "bundle": "tsbundle",
"startTs": "node cli.ts.js", "startTs": "node cli.ts.js",
"watch": "tswatch", "watch": "tswatch",
@@ -20,35 +20,35 @@
"tsview": "cli.js" "tsview": "cli.js"
}, },
"devDependencies": { "devDependencies": {
"@api.global/typedsocket": "^4.1.0", "@api.global/typedsocket": "^4.1.2",
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8", "@git.zone/tstest": "^3.5.1",
"@git.zone/tswatch": "3.0.1", "@git.zone/tswatch": "^3.3.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@types/node": "^25.0.10" "@types/node": "^25.5.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.2.5", "@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.4.6",
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.1015.0",
"@design.estate/dees-catalog": "^3.41.1", "@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.2.3",
"@push.rocks/early": "^4.0.4", "@push.rocks/early": "^4.0.4",
"@push.rocks/npmextra": "^5.3.3", "@push.rocks/smartbucket": "^4.5.1",
"@push.rocks/smartbucket": "^4.4.1",
"@push.rocks/smartcli": "^4.0.20", "@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartconfig": "^6.0.1",
"@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartlog-destination-local": "^9.0.2", "@push.rocks/smartlog-destination-local": "^9.0.2",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartopen": "^2.0.0", "@push.rocks/smartopen": "^2.0.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"mongodb": "^7.0.0" "mongodb": "^7.1.1"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@@ -59,7 +59,7 @@
"dist_ts_web/**/*", "dist_ts_web/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"readme.md" "readme.md"
], ],
"browserslist": [ "browserslist": [

5399
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

132
readme.md
View File

@@ -1,6 +1,6 @@
# @git.zone/tsview # @git.zone/tsview
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. 🚀 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 during development.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -16,51 +16,11 @@ pnpm add -g @git.zone/tsview
pnpm add @git.zone/tsview pnpm add @git.zone/tsview
``` ```
## Features ✨ ## 🚀 Quick Start
### 🗄️ S3 Storage Browser
- **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
- **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
- ⌨️ Context menus for quick actions
- 🔌 Everything bundled — zero external runtime dependencies in the browser
## Quick Start 🚀
### 1. Configure Your Connection ### 1. Configure Your Connection
Create a `.nogit/env.json` file in your project root: Create a `.nogit/env.json` file in your project root (auto-generated by `gitzone service`):
```json ```json
{ {
@@ -80,7 +40,50 @@ Create a `.nogit/env.json` file in your project root:
tsview tsview
``` ```
That's it! 🎉 Your browser will automatically open to the viewer interface. That's it! Your browser opens automatically to the viewer interface.
## ✨ Features
### 🗄️ S3 Storage Browser
Powered by `dees-storage-browser` from `@design.estate/dees-catalog`:
- **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, code, and more directly in the browser
- **Bucket Management** — Create, delete, and switch between buckets
- **File Operations** — Upload, download, delete, move, and copy objects
- **In-place Text Editing** — Edit text files directly 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
- **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
- **Aggregation Pipeline** — Run aggregation queries directly
- **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
- Context menus for quick actions
- Everything bundled — zero external runtime dependencies in the browser
## CLI Usage ## CLI Usage
@@ -113,7 +116,7 @@ const viewer = new TsView();
await viewer.loadConfigFromEnv(); await viewer.loadConfigFromEnv();
// Option 2: Configure programmatically // Option 2: Configure programmatically
viewer.setS3Config({ viewer.setStorageConfig({
endpoint: 'localhost', endpoint: 'localhost',
port: 9000, port: 9000,
accessKey: 'minioadmin', accessKey: 'minioadmin',
@@ -127,7 +130,7 @@ viewer.setMongoConfig({
}); });
// Option 3: Cloud services // Option 3: Cloud services
viewer.setS3Config({ viewer.setStorageConfig({
endpoint: 's3.amazonaws.com', endpoint: 's3.amazonaws.com',
accessKey: 'AKIAXXXXXXX', accessKey: 'AKIAXXXXXXX',
accessSecret: 'your-secret-key', accessSecret: 'your-secret-key',
@@ -153,7 +156,7 @@ await viewer.stop();
## Configuration ## Configuration
### Project-level via `npmextra.json` ### Project-level via `.smartconfig.json`
```json ```json
{ {
@@ -171,7 +174,7 @@ await viewer.stop();
| `killIfBusy` | `boolean` | `false` | Kill existing process if port is busy | | `killIfBusy` | `boolean` | `false` | Kill existing process if port is busy |
| `openBrowser` | `boolean` | `true` | Automatically open browser on start | | `openBrowser` | `boolean` | `true` | Automatically open browser on start |
**Port priority:** CLI `--port` flag → `npmextra.json` → auto-detect **Port priority:** CLI `--port` flag → `.smartconfig.json` → auto-detect
### Environment Variables (`.nogit/env.json`) ### Environment Variables (`.nogit/env.json`)
@@ -206,15 +209,15 @@ Or use individual variables:
tsview works with any S3-compatible storage: tsview works with any S3-compatible storage:
| Provider | Status | | Provider | Status |
| ----------------------- | --------------------------- | | ----------------------- | -------------------------- |
| **MinIO** | Perfect for local dev | | **MinIO** | Perfect for local dev |
| **AWS S3** | Amazon's object storage | | **AWS S3** | Amazon's object storage |
| **DigitalOcean Spaces** | Simple object storage | | **DigitalOcean Spaces** | Simple object storage |
| **Backblaze B2** | S3-compatible API | | **Backblaze B2** | S3-compatible API |
| **Cloudflare R2** | Zero egress fees | | **Cloudflare R2** | Zero egress fees |
| **Wasabi** | Hot cloud storage | | **Wasabi** | Hot cloud storage |
| **Self-hosted** | Any S3-compatible server | | **Self-hosted** | Any S3-compatible server |
## Supported File Types for Preview ## Supported File Types for Preview
@@ -226,11 +229,11 @@ tsview works with any S3-compatible storage:
| **Data** | `.csv`, `.xml`, `.yaml`, `.yml` | | **Data** | `.csv`, `.xml`, `.yaml`, `.yml` |
| **Documents** | `.pdf` | | **Documents** | `.pdf` |
## Architecture ## 🏗️ Architecture
``` ```
tsview/ tsview/
├── ts/ # Backend ├── ts/ # Backend (Node.js)
│ ├── api/ # TypedRequest API handlers │ ├── api/ # TypedRequest API handlers
│ │ ├── handlers.s3.ts # S3 bucket & object operations │ │ ├── handlers.s3.ts # S3 bucket & object operations
│ │ └── handlers.mongodb.ts # MongoDB CRUD & admin operations │ │ └── handlers.mongodb.ts # MongoDB CRUD & admin operations
@@ -241,23 +244,24 @@ tsview/
│ │ └── interfaces.streaming.ts # Subscription interfaces │ │ └── interfaces.streaming.ts # Subscription interfaces
│ ├── interfaces/ # Shared TypeScript interfaces │ ├── interfaces/ # Shared TypeScript interfaces
│ └── tsview.classes.tsview.ts # Main class │ └── tsview.classes.tsview.ts # Main class
├── ts_web/ # Frontend ├── ts_web/ # Frontend (bundled via esbuild → base64ts)
│ ├── elements/ # Web components (LitElement) │ ├── elements/ # Web components (LitElement)
│ │ ├── tsview-app.ts # App shell + navigation │ │ ├── tsview-app.ts # App shell + navigation
│ │ ├── tsview-s3-*.ts # S3 browser components
│ │ ├── tsview-mongo-*.ts # MongoDB browser components │ │ ├── tsview-mongo-*.ts # MongoDB browser components
│ │ └── tsview-activity-stream.ts # Real-time activity feed │ │ └── tsview-activity-stream.ts # Real-time activity feed
│ ├── adapters/ # Data provider adapters
│ │ └── s3-data-provider.ts # IStorageDataProvider for dees-storage-browser
│ ├── services/ # API + WebSocket clients │ ├── services/ # API + WebSocket clients
│ ├── styles/ # Dark theme │ ├── styles/ # Dark theme
│ └── utilities/ # Formatting helpers │ └── utilities/ # Formatting helpers
└── cli.ts.js # CLI entry point └── .smartconfig.json # Build & runtime config
``` ```
### How It Works ### 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. 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. 2. **Frontend** — LitElement-based web components communicate with the backend via `TypedRequest`. The S3 browser uses `dees-storage-browser` from `@design.estate/dees-catalog` with a custom `IStorageDataProvider` adapter. 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. 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.
@@ -283,7 +287,7 @@ pnpm test
## License and Legal Information ## 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. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -10,7 +10,7 @@ tsview/
├── cli.ts.js # Dev CLI (uses tsrun to run cli.child.ts) ├── cli.ts.js # Dev CLI (uses tsrun to run cli.child.ts)
├── cli.child.ts # Dev CLI entry (imports ts/index.js) ├── cli.child.ts # Dev CLI entry (imports ts/index.js)
├── package.json # bin: { "tsview": "cli.js" } ├── package.json # bin: { "tsview": "cli.js" }
├── npmextra.json # tsbundle config for UI bundling ├── .smartconfig.json # tsbundle/tswatch config
├── tsconfig.json ├── tsconfig.json
├── readme.md ├── readme.md
├── readme.hints.md ├── readme.hints.md

View File

@@ -14,8 +14,8 @@ tap.test('should create TsView instance', async () => {
tap.test('should have config methods', async () => { tap.test('should have config methods', async () => {
const viewer = new tsview.TsView(); const viewer = new tsview.TsView();
// Set S3 config // Set storage config
viewer.setS3Config({ viewer.setStorageConfig({
endpoint: 'localhost', endpoint: 'localhost',
port: 9000, port: 9000,
accessKey: 'test', accessKey: 'test',
@@ -23,7 +23,7 @@ tap.test('should have config methods', async () => {
useSsl: false, useSsl: false,
}); });
expect(viewer.config.hasS3()).toBeTrue(); expect(viewer.config.hasStorage()).toBeTrue();
expect(viewer.config.hasMongo()).toBeFalse(); expect(viewer.config.hasMongo()).toBeFalse();
// Set MongoDB config // Set MongoDB config

View File

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

View File

@@ -119,7 +119,7 @@ export async function registerS3Handlers(
} }
} }
const objects: interfaces.IS3Object[] = []; const objects: interfaces.IStorageObject[] = [];
const prefixSet = new Set<string>(); const prefixSet = new Set<string>();
// List files in current directory // List files in current directory
@@ -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) };
}
}
)
);
} }

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@ import type * as interfaces from '../interfaces/index.js';
* or accepts programmatic configuration. * or accepts programmatic configuration.
*/ */
export class TsViewConfig { export class TsViewConfig {
private s3Config: interfaces.IS3Config | null = null; private storageConfig: interfaces.IStorageConfig | null = null;
private mongoConfig: interfaces.IMongoConfig | null = null; private mongoConfig: interfaces.IMongoConfig | null = null;
/** /**
@@ -29,7 +29,7 @@ export class TsViewConfig {
// Parse S3 config // Parse S3 config
if (envConfig.S3_HOST || envConfig.S3_ENDPOINT) { if (envConfig.S3_HOST || envConfig.S3_ENDPOINT) {
this.s3Config = { this.storageConfig = {
endpoint: envConfig.S3_ENDPOINT || envConfig.S3_HOST || '', endpoint: envConfig.S3_ENDPOINT || envConfig.S3_HOST || '',
port: envConfig.S3_PORT ? parseInt(envConfig.S3_PORT, 10) : undefined, port: envConfig.S3_PORT ? parseInt(envConfig.S3_PORT, 10) : undefined,
accessKey: envConfig.S3_ACCESSKEY || '', accessKey: envConfig.S3_ACCESSKEY || '',
@@ -69,8 +69,8 @@ export class TsViewConfig {
/** /**
* Set S3 configuration programmatically * Set S3 configuration programmatically
*/ */
public setS3Config(config: interfaces.IS3Config): void { public setStorageConfig(config: interfaces.IStorageConfig): void {
this.s3Config = config; this.storageConfig = config;
} }
/** /**
@@ -83,8 +83,8 @@ export class TsViewConfig {
/** /**
* Get S3 configuration * Get S3 configuration
*/ */
public getS3Config(): interfaces.IS3Config | null { public getStorageConfig(): interfaces.IStorageConfig | null {
return this.s3Config; return this.storageConfig;
} }
/** /**
@@ -97,8 +97,8 @@ export class TsViewConfig {
/** /**
* Check if S3 is configured * Check if S3 is configured
*/ */
public hasS3(): boolean { public hasStorage(): boolean {
return this.s3Config !== null && !!this.s3Config.endpoint && !!this.s3Config.accessKey; return this.storageConfig !== null && !!this.storageConfig.endpoint && !!this.storageConfig.accessKey;
} }
/** /**

View File

@@ -1,9 +1,9 @@
import type * as plugins from '../plugins.js'; import type * as plugins from '../plugins.js';
/** /**
* Configuration for S3 connection * Configuration for storage (S3-compatible) connection
*/ */
export interface IS3Config { export interface IStorageConfig {
endpoint: string; endpoint: string;
port?: number; port?: number;
accessKey: string; accessKey: string;
@@ -24,14 +24,14 @@ export interface IMongoConfig {
* Combined configuration for tsview * Combined configuration for tsview
*/ */
export interface ITsViewConfig { export interface ITsViewConfig {
s3?: IS3Config; s3?: IStorageConfig;
mongo?: IMongoConfig; mongo?: IMongoConfig;
} }
/** /**
* Configuration from npmextra.json for @git.zone/tsview * Configuration from smartconfig.json for @git.zone/tsview
*/ */
export interface INpmextraConfig { export interface ISmartconfigConfig {
port?: number; // Fixed port to use (optional) port?: number; // Fixed port to use (optional)
killIfBusy?: boolean; // Kill process on port if busy (default: false) killIfBusy?: boolean; // Kill process on port if busy (default: false)
openBrowser?: boolean; // Open browser on start (default: true) openBrowser?: boolean; // Open browser on start (default: true)
@@ -97,7 +97,7 @@ export interface IReq_DeleteBucket extends plugins.typedrequestInterfaces.implem
}; };
} }
export interface IS3Object { export interface IStorageObject {
key: string; key: string;
size?: number; size?: number;
lastModified?: string; lastModified?: string;
@@ -115,7 +115,7 @@ export interface IReq_ListObjects extends plugins.typedrequestInterfaces.impleme
delimiter?: string; delimiter?: string;
}; };
response: { response: {
objects: IS3Object[]; objects: IStorageObject[];
prefixes: string[]; prefixes: string[];
}; };
} }
@@ -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< export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetObjectUrl IReq_GetObjectUrl

View File

@@ -6,7 +6,7 @@ export { path };
import * as early from '@push.rocks/early'; import * as early from '@push.rocks/early';
early.start('tsview'); early.start('tsview');
import * as npmextra from '@push.rocks/npmextra'; import * as smartconfig from '@push.rocks/smartconfig';
import * as smartbucket from '@push.rocks/smartbucket'; import * as smartbucket from '@push.rocks/smartbucket';
import * as smartcli from '@push.rocks/smartcli'; import * as smartcli from '@push.rocks/smartcli';
import * as smartdata from '@push.rocks/smartdata'; import * as smartdata from '@push.rocks/smartdata';
@@ -21,7 +21,7 @@ import * as smartrx from '@push.rocks/smartrx';
export { export {
early, early,
npmextra, smartconfig,
smartbucket, smartbucket,
smartcli, smartcli,
smartdata, smartdata,

View File

@@ -39,7 +39,7 @@ export class ViewServer {
this.changeStreamManager = new ChangeStreamManager(this.tsview); this.changeStreamManager = new ChangeStreamManager(this.tsview);
// Register API handlers directly to server's router // Register API handlers directly to server's router
if (this.tsview.config.hasS3()) { if (this.tsview.config.hasStorage()) {
await registerS3Handlers(this.typedServer.typedrouter, this.tsview); await registerS3Handlers(this.typedServer.typedrouter, this.tsview);
} }
@@ -107,9 +107,9 @@ export class ViewServer {
) )
); );
// Subscribe to S3 bucket changes // Subscribe to storage bucket changes
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_SubscribeS3>( new plugins.typedrequest.TypedHandler<interfaces.IReq_SubscribeStorage>(
'subscribeS3', 'subscribeS3',
async (reqData, context) => { async (reqData, context) => {
const connectionId = this.getConnectionId(context); const connectionId = this.getConnectionId(context);
@@ -127,9 +127,9 @@ export class ViewServer {
) )
); );
// Unsubscribe from S3 bucket changes // Unsubscribe from storage bucket changes
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_UnsubscribeS3>( new plugins.typedrequest.TypedHandler<interfaces.IReq_UnsubscribeStorage>(
'unsubscribeS3', 'unsubscribeS3',
async (reqData, context) => { async (reqData, context) => {
const connectionId = this.getConnectionId(context); const connectionId = this.getConnectionId(context);

View File

@@ -1,7 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { TsView } from '../tsview.classes.tsview.js'; import type { TsView } from '../tsview.classes.tsview.js';
import type * as interfaces from './interfaces.streaming.js'; import type * as interfaces from './interfaces.streaming.js';
import type { IS3ChangeEvent } from '@push.rocks/smartbucket'; import type { IStorageChangeEvent } from './interfaces.streaming.js';
/** /**
* Subscription entry tracking a client's subscription to a resource * Subscription entry tracking a client's subscription to a resource
@@ -21,19 +21,19 @@ interface IMongoWatcherEntry {
} }
/** /**
* S3 watcher entry * Storage watcher entry
*/ */
interface IS3WatcherEntry { interface IStorageWatcherEntry {
watcher: plugins.smartbucket.BucketWatcher; watcher: plugins.smartbucket.BucketWatcher;
subscriptions: Map<string, ISubscriptionEntry>; // connectionId -> subscription subscriptions: Map<string, ISubscriptionEntry>; // connectionId -> subscription
} }
/** /**
* ChangeStreamManager manages real-time change streaming for both MongoDB and S3. * ChangeStreamManager manages real-time change streaming for both MongoDB and storage.
* *
* Features: * Features:
* - MongoDB Change Streams for real-time database updates * - MongoDB Change Streams for real-time database updates
* - S3 BucketWatcher for polling-based S3 change detection * - S3 BucketWatcher for polling-based storage change detection
* - Subscription management per WebSocket client * - Subscription management per WebSocket client
* - Activity stream with ring buffer for recent events * - Activity stream with ring buffer for recent events
* - Automatic cleanup on client disconnect * - Automatic cleanup on client disconnect
@@ -45,8 +45,8 @@ export class ChangeStreamManager {
// MongoDB watchers: "db/collection" -> watcher entry // MongoDB watchers: "db/collection" -> watcher entry
private mongoWatchers: Map<string, IMongoWatcherEntry> = new Map(); private mongoWatchers: Map<string, IMongoWatcherEntry> = new Map();
// S3 watchers: "bucket/prefix" -> watcher entry // Storage watchers: "bucket/prefix" -> watcher entry
private s3Watchers: Map<string, IS3WatcherEntry> = new Map(); private storageWatchers: Map<string, IStorageWatcherEntry> = new Map();
// Activity subscribers: connectionId -> subscription entry // Activity subscribers: connectionId -> subscription entry
private activitySubscribers: Map<string, ISubscriptionEntry> = new Map(); private activitySubscribers: Map<string, ISubscriptionEntry> = new Map();
@@ -57,7 +57,7 @@ export class ChangeStreamManager {
// Global watchers for the activity stream (started lazily on first subscriber) // Global watchers for the activity stream (started lazily on first subscriber)
private globalMongoWatcher: plugins.mongodb.ChangeStream | null = null; private globalMongoWatcher: plugins.mongodb.ChangeStream | null = null;
private globalS3Watchers: Map<string, plugins.smartbucket.BucketWatcher> = new Map(); private globalStorageWatchers: Map<string, plugins.smartbucket.BucketWatcher> = new Map();
private globalWatchersActive: boolean = false; private globalWatchersActive: boolean = false;
// Counter for generating unique subscription IDs // Counter for generating unique subscription IDs
@@ -89,9 +89,9 @@ export class ChangeStreamManager {
} }
/** /**
* Get the S3 key for a bucket/prefix pair * Get the storage key for a bucket/prefix pair
*/ */
private getS3Key(bucket: string, prefix?: string): string { private getStorageKey(bucket: string, prefix?: string): string {
return prefix ? `${bucket}/${prefix}` : bucket; return prefix ? `${bucket}/${prefix}` : bucket;
} }
@@ -280,24 +280,24 @@ export class ChangeStreamManager {
} }
// =========================================== // ===========================================
// S3 Change Watching // Storage Change Watching
// =========================================== // ===========================================
/** /**
* Subscribe a client to S3 bucket/prefix changes * Subscribe a client to storage bucket/prefix changes
*/ */
public async subscribeToS3( public async subscribeToS3(
connectionId: string, connectionId: string,
bucket: string, bucket: string,
prefix?: string prefix?: string
): Promise<{ success: boolean; subscriptionId: string }> { ): Promise<{ success: boolean; subscriptionId: string }> {
const key = this.getS3Key(bucket, prefix); const key = this.getStorageKey(bucket, prefix);
let entry = this.s3Watchers.get(key); let entry = this.storageWatchers.get(key);
// Create watcher if it doesn't exist // Create watcher if it doesn't exist
if (!entry) { if (!entry) {
const watcher = await this.createS3Watcher(bucket, prefix); const watcher = await this.createStorageWatcher(bucket, prefix);
if (!watcher) { if (!watcher) {
return { success: false, subscriptionId: '' }; return { success: false, subscriptionId: '' };
} }
@@ -306,7 +306,7 @@ export class ChangeStreamManager {
watcher, watcher,
subscriptions: new Map(), subscriptions: new Map(),
}; };
this.s3Watchers.set(key, entry); this.storageWatchers.set(key, entry);
} }
// Add subscription // Add subscription
@@ -317,47 +317,47 @@ export class ChangeStreamManager {
createdAt: new Date(), createdAt: new Date(),
}); });
console.log(`[ChangeStream] S3 subscription added: ${key} for connection ${connectionId}`); console.log(`[ChangeStream] Storage subscription added: ${key} for connection ${connectionId}`);
return { success: true, subscriptionId }; return { success: true, subscriptionId };
} }
/** /**
* Unsubscribe a client from S3 bucket/prefix changes * Unsubscribe a client from storage bucket/prefix changes
*/ */
public async unsubscribeFromS3( public async unsubscribeFromS3(
connectionId: string, connectionId: string,
bucket: string, bucket: string,
prefix?: string prefix?: string
): Promise<boolean> { ): Promise<boolean> {
const key = this.getS3Key(bucket, prefix); const key = this.getStorageKey(bucket, prefix);
const entry = this.s3Watchers.get(key); const entry = this.storageWatchers.get(key);
if (!entry) { if (!entry) {
return false; return false;
} }
entry.subscriptions.delete(connectionId); entry.subscriptions.delete(connectionId);
console.log(`[ChangeStream] S3 subscription removed: ${key} for connection ${connectionId}`); console.log(`[ChangeStream] Storage subscription removed: ${key} for connection ${connectionId}`);
// Close watcher if no more subscribers // Close watcher if no more subscribers
if (entry.subscriptions.size === 0) { if (entry.subscriptions.size === 0) {
await this.closeS3Watcher(key); await this.closeStorageWatcher(key);
} }
return true; return true;
} }
/** /**
* Create an S3 bucket watcher * Create a storage bucket watcher
*/ */
private async createS3Watcher( private async createStorageWatcher(
bucket: string, bucket: string,
prefix?: string prefix?: string
): Promise<plugins.smartbucket.BucketWatcher | null> { ): Promise<plugins.smartbucket.BucketWatcher | null> {
try { try {
const smartbucket = await this.tsview.getSmartBucket(); const smartbucket = await this.tsview.getSmartBucket();
if (!smartbucket) { if (!smartbucket) {
console.error('[ChangeStream] S3 not configured'); console.error('[ChangeStream] Storage not configured');
return null; return null;
} }
@@ -371,10 +371,10 @@ export class ChangeStreamManager {
}); });
// Subscribe to change events // Subscribe to change events
watcher.changeSubject.subscribe((eventOrEvents: IS3ChangeEvent | IS3ChangeEvent[]) => { watcher.changeSubject.subscribe((eventOrEvents: IStorageChangeEvent | IStorageChangeEvent[]) => {
const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents]; const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents];
for (const event of events) { for (const event of events) {
this.handleS3Change(bucket, prefix, event); this.handleStorageChange(bucket, prefix, event);
} }
}); });
@@ -382,41 +382,41 @@ export class ChangeStreamManager {
await watcher.start(); await watcher.start();
await watcher.readyDeferred.promise; await watcher.readyDeferred.promise;
console.log(`[ChangeStream] S3 watcher created for ${bucket}${prefix ? '/' + prefix : ''}`); console.log(`[ChangeStream] Storage watcher created for ${bucket}${prefix ? '/' + prefix : ''}`);
return watcher; return watcher;
} catch (error) { } catch (error) {
console.error(`[ChangeStream] Failed to create S3 watcher for ${bucket}:`, error); console.error(`[ChangeStream] Failed to create storage watcher for ${bucket}:`, error);
return null; return null;
} }
} }
/** /**
* Handle an S3 change event * Handle a storage change event
*/ */
private handleS3Change(bucket: string, prefix: string | undefined, event: IS3ChangeEvent): void { private handleStorageChange(bucket: string, prefix: string | undefined, event: IStorageChangeEvent): void {
const key = this.getS3Key(bucket, prefix); const key = this.getStorageKey(bucket, prefix);
const entry = this.s3Watchers.get(key); const entry = this.storageWatchers.get(key);
if (!entry) return; if (!entry) return;
// Only add to activity buffer if global watchers are NOT active. // Only add to activity buffer if global watchers are NOT active.
// When active, the global S3 watchers already feed the activity stream. // When active, the global storage watchers already feed the activity stream.
if (!this.globalWatchersActive) { if (!this.globalWatchersActive) {
this.addToActivityBuffer('s3', event); this.addToActivityBuffer('storage', event);
} }
// Push to all subscribed clients // Push to all subscribed clients
this.pushS3ChangeToClients(key, event); this.pushStorageChangeToClients(key, event);
} }
/** /**
* Push S3 change to subscribed clients * Push storage change to subscribed clients
*/ */
private async pushS3ChangeToClients( private async pushStorageChangeToClients(
key: string, key: string,
event: IS3ChangeEvent event: IStorageChangeEvent
): Promise<void> { ): Promise<void> {
const entry = this.s3Watchers.get(key); const entry = this.storageWatchers.get(key);
if (!entry || !this.typedSocket) return; if (!entry || !this.typedSocket) return;
for (const [connectionId, _sub] of entry.subscriptions) { for (const [connectionId, _sub] of entry.subscriptions) {
@@ -426,31 +426,31 @@ export class ChangeStreamManager {
}); });
if (connection) { if (connection) {
const request = this.typedSocket.createTypedRequest<interfaces.IReq_PushS3Change>( const request = this.typedSocket.createTypedRequest<interfaces.IReq_PushStorageChange>(
'pushS3Change', 'pushS3Change',
connection connection
); );
await request.fire({ event }); await request.fire({ event });
} }
} catch (error) { } catch (error) {
console.error(`[ChangeStream] Failed to push S3 change to ${connectionId}:`, error); console.error(`[ChangeStream] Failed to push storage change to ${connectionId}:`, error);
} }
} }
} }
/** /**
* Close an S3 bucket watcher * Close a storage bucket watcher
*/ */
private async closeS3Watcher(key: string): Promise<void> { private async closeStorageWatcher(key: string): Promise<void> {
const entry = this.s3Watchers.get(key); const entry = this.storageWatchers.get(key);
if (!entry) return; if (!entry) return;
try { try {
await entry.watcher.stop(); await entry.watcher.stop();
this.s3Watchers.delete(key); this.storageWatchers.delete(key);
console.log(`[ChangeStream] S3 watcher closed for ${key}`); console.log(`[ChangeStream] Storage watcher closed for ${key}`);
} catch (error) { } catch (error) {
console.error(`[ChangeStream] Error closing S3 watcher for ${key}:`, error); console.error(`[ChangeStream] Error closing storage watcher for ${key}:`, error);
} }
} }
@@ -515,11 +515,11 @@ export class ChangeStreamManager {
* Add an event to the activity buffer * Add an event to the activity buffer
*/ */
private addToActivityBuffer( private addToActivityBuffer(
source: 'mongodb' | 's3', source: 'mongodb' | 'storage',
event: interfaces.IMongoChangeEvent | IS3ChangeEvent event: interfaces.IMongoChangeEvent | IStorageChangeEvent
): void { ): void {
const activityEvent: interfaces.IActivityEvent = { const activityEvent: interfaces.IActivityEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, id: `evt_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
source, source,
event, event,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -567,7 +567,7 @@ export class ChangeStreamManager {
/** /**
* Start global watchers when the first activity subscriber connects. * Start global watchers when the first activity subscriber connects.
* These watch all MongoDB and S3 activity and feed into the activity buffer. * These watch all MongoDB and storage activity and feed into the activity buffer.
*/ */
private async startGlobalWatchers(): Promise<void> { private async startGlobalWatchers(): Promise<void> {
if (this.globalWatchersActive) return; if (this.globalWatchersActive) return;
@@ -577,7 +577,7 @@ export class ChangeStreamManager {
await Promise.all([ await Promise.all([
this.startGlobalMongoWatcher(), this.startGlobalMongoWatcher(),
this.startGlobalS3Watchers(), this.startGlobalStorageWatchers(),
]); ]);
} }
@@ -628,13 +628,13 @@ export class ChangeStreamManager {
} }
/** /**
* Start S3 bucket watchers — one BucketWatcher per bucket. * Start storage bucket watchers — one BucketWatcher per bucket.
*/ */
private async startGlobalS3Watchers(): Promise<void> { private async startGlobalStorageWatchers(): Promise<void> {
try { try {
const smartbucket = await this.tsview.getSmartBucket(); const smartbucket = await this.tsview.getSmartBucket();
if (!smartbucket) { if (!smartbucket) {
console.log('[ChangeStream] S3 not configured, skipping global S3 watchers'); console.log('[ChangeStream] Storage not configured, skipping global storage watchers');
return; return;
} }
@@ -652,26 +652,26 @@ export class ChangeStreamManager {
bufferTimeMs: 500, bufferTimeMs: 500,
}); });
watcher.changeSubject.subscribe((eventOrEvents: IS3ChangeEvent | IS3ChangeEvent[]) => { watcher.changeSubject.subscribe((eventOrEvents: IStorageChangeEvent | IStorageChangeEvent[]) => {
const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents]; const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents];
for (const event of events) { for (const event of events) {
this.addToActivityBuffer('s3', event); this.addToActivityBuffer('storage', event);
} }
}); });
await watcher.start(); await watcher.start();
await watcher.readyDeferred.promise; await watcher.readyDeferred.promise;
this.globalS3Watchers.set(bucketName, watcher); this.globalStorageWatchers.set(bucketName, watcher);
console.log(`[ChangeStream] Global S3 watcher started for bucket: ${bucketName}`); console.log(`[ChangeStream] Global storage watcher started for bucket: ${bucketName}`);
} catch (bucketError) { } catch (bucketError) {
console.error(`[ChangeStream] Failed to start global S3 watcher for bucket ${bucketName}:`, bucketError); console.error(`[ChangeStream] Failed to start global storage watcher for bucket ${bucketName}:`, bucketError);
} }
} }
console.log(`[ChangeStream] Global S3 watchers started (${this.globalS3Watchers.size}/${bucketNames.length} buckets)`); console.log(`[ChangeStream] Global storage watchers started (${this.globalStorageWatchers.size}/${bucketNames.length} buckets)`);
} catch (error) { } catch (error) {
console.error('[ChangeStream] Failed to start global S3 watchers:', error); console.error('[ChangeStream] Failed to start global storage watchers:', error);
} }
} }
@@ -694,16 +694,16 @@ export class ChangeStreamManager {
this.globalMongoWatcher = null; this.globalMongoWatcher = null;
} }
// Close all global S3 watchers // Close all global storage watchers
for (const [bucketName, watcher] of this.globalS3Watchers) { for (const [bucketName, watcher] of this.globalStorageWatchers) {
try { try {
await watcher.stop(); await watcher.stop();
console.log(`[ChangeStream] Global S3 watcher stopped for bucket: ${bucketName}`); console.log(`[ChangeStream] Global storage watcher stopped for bucket: ${bucketName}`);
} catch (error) { } catch (error) {
console.error(`[ChangeStream] Error closing global S3 watcher for ${bucketName}:`, error); console.error(`[ChangeStream] Error closing global storage watcher for ${bucketName}:`, error);
} }
} }
this.globalS3Watchers.clear(); this.globalStorageWatchers.clear();
this.globalWatchersActive = false; this.globalWatchersActive = false;
console.log('[ChangeStream] Global watchers stopped'); console.log('[ChangeStream] Global watchers stopped');
@@ -729,12 +729,12 @@ export class ChangeStreamManager {
} }
} }
// Clean up S3 subscriptions // Clean up storage subscriptions
for (const [key, entry] of this.s3Watchers) { for (const [key, entry] of this.storageWatchers) {
if (entry.subscriptions.has(connectionId)) { if (entry.subscriptions.has(connectionId)) {
entry.subscriptions.delete(connectionId); entry.subscriptions.delete(connectionId);
if (entry.subscriptions.size === 0) { if (entry.subscriptions.size === 0) {
await this.closeS3Watcher(key); await this.closeStorageWatcher(key);
} }
} }
} }
@@ -762,9 +762,9 @@ export class ChangeStreamManager {
await this.closeMongoWatcher(key); await this.closeMongoWatcher(key);
} }
// Close all S3 watchers // Close all storage watchers
for (const key of this.s3Watchers.keys()) { for (const key of this.storageWatchers.keys()) {
await this.closeS3Watcher(key); await this.closeStorageWatcher(key);
} }
// Clear activity buffer and subscribers // Clear activity buffer and subscribers

View File

@@ -1,7 +1,8 @@
import type * as plugins from '../plugins.js'; import type * as plugins from '../plugins.js';
// Re-export S3 change event from smartbucket // Re-export storage change event from smartbucket
export type { IS3ChangeEvent } from '@push.rocks/smartbucket'; import type { IStorageChangeEvent } from '@push.rocks/smartbucket';
export type { IStorageChangeEvent };
/** /**
* MongoDB change event - wraps smartdata watcher output * MongoDB change event - wraps smartdata watcher output
@@ -24,8 +25,8 @@ export interface IMongoChangeEvent {
*/ */
export interface IActivityEvent { export interface IActivityEvent {
id: string; id: string;
source: 'mongodb' | 's3'; source: 'mongodb' | 'storage';
event: IMongoChangeEvent | import('@push.rocks/smartbucket').IS3ChangeEvent; event: IMongoChangeEvent | IStorageChangeEvent;
timestamp: string; timestamp: string;
} }
@@ -69,11 +70,11 @@ export interface IReq_UnsubscribeMongo extends plugins.typedrequestInterfaces.im
} }
/** /**
* Subscribe to S3 bucket/prefix changes * Subscribe to storage bucket/prefix changes
*/ */
export interface IReq_SubscribeS3 extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_SubscribeStorage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_SubscribeS3 IReq_SubscribeStorage
> { > {
method: 'subscribeS3'; method: 'subscribeS3';
request: { request: {
@@ -87,11 +88,11 @@ export interface IReq_SubscribeS3 extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Unsubscribe from S3 bucket/prefix changes * Unsubscribe from storage bucket/prefix changes
*/ */
export interface IReq_UnsubscribeS3 extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UnsubscribeStorage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_UnsubscribeS3 IReq_UnsubscribeStorage
> { > {
method: 'unsubscribeS3'; method: 'unsubscribeS3';
request: { request: {
@@ -104,7 +105,7 @@ export interface IReq_UnsubscribeS3 extends plugins.typedrequestInterfaces.imple
} }
/** /**
* Subscribe to activity stream (all changes from MongoDB and S3) * Subscribe to activity stream (all changes from MongoDB and storage)
*/ */
export interface IReq_SubscribeActivity extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_SubscribeActivity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -169,15 +170,15 @@ export interface IReq_PushMongoChange extends plugins.typedrequestInterfaces.imp
} }
/** /**
* Server pushes S3 change to client * Server pushes storage change to client
*/ */
export interface IReq_PushS3Change extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_PushStorageChange extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushS3Change IReq_PushStorageChange
> { > {
method: 'pushS3Change'; method: 'pushS3Change';
request: { request: {
event: import('@push.rocks/smartbucket').IS3ChangeEvent; event: IStorageChangeEvent;
}; };
response: { response: {
received: boolean; received: boolean;
@@ -206,7 +207,7 @@ export interface IReq_PushActivityEvent extends plugins.typedrequestInterfaces.i
export interface ISubscriptionTag extends plugins.typedrequestInterfaces.ITag { export interface ISubscriptionTag extends plugins.typedrequestInterfaces.ITag {
name: 'subscription'; name: 'subscription';
payload: { payload: {
type: 'mongo' | 's3' | 'activity'; type: 'mongo' | 'storage' | 'activity';
key: string; // e.g., "db/collection" or "bucket/prefix" or "activity" key: string; // e.g., "db/collection" or "bucket/prefix" or "activity"
}; };
} }

View File

@@ -33,8 +33,8 @@ export class TsView {
/** /**
* Set S3 configuration programmatically * Set S3 configuration programmatically
*/ */
public setS3Config(config: interfaces.IS3Config): void { public setStorageConfig(config: interfaces.IStorageConfig): void {
this.config.setS3Config(config); this.config.setStorageConfig(config);
} }
/** /**
@@ -52,17 +52,17 @@ export class TsView {
return this.smartbucketInstance; return this.smartbucketInstance;
} }
const s3Config = this.config.getS3Config(); const storageConfig = this.config.getStorageConfig();
if (!s3Config) { if (!storageConfig) {
return null; return null;
} }
this.smartbucketInstance = new plugins.smartbucket.SmartBucket({ this.smartbucketInstance = new plugins.smartbucket.SmartBucket({
endpoint: s3Config.endpoint, endpoint: storageConfig.endpoint,
port: s3Config.port, port: storageConfig.port,
accessKey: s3Config.accessKey, accessKey: storageConfig.accessKey,
accessSecret: s3Config.accessSecret, accessSecret: storageConfig.accessSecret,
useSsl: s3Config.useSsl ?? true, useSsl: storageConfig.useSsl ?? true,
}); });
return this.smartbucketInstance; return this.smartbucketInstance;
@@ -103,11 +103,11 @@ export class TsView {
} }
/** /**
* Load configuration from npmextra.json * Load configuration from smartconfig.json
*/ */
private loadNpmextraConfig(cwd?: string): interfaces.INpmextraConfig { private loadSmartconfigConfig(cwd?: string): interfaces.ISmartconfigConfig {
const npmextra = new plugins.npmextra.Npmextra(cwd || process.cwd()); const smartconfigInstance = new plugins.smartconfig.Smartconfig(cwd || process.cwd());
const config = npmextra.dataFor<interfaces.INpmextraConfig>('@git.zone/tsview', {}); const config = smartconfigInstance.dataFor<interfaces.ISmartconfigConfig>('@git.zone/tsview', {});
return config || {}; return config || {};
} }
@@ -135,7 +135,7 @@ export class TsView {
* @param cliPort - Optional port number from CLI (highest priority) * @param cliPort - Optional port number from CLI (highest priority)
*/ */
public async start(cliPort?: number): Promise<number> { public async start(cliPort?: number): Promise<number> {
const npmextraConfig = await this.loadNpmextraConfig(); const smartconfigConfig = await this.loadSmartconfigConfig();
let port: number; let port: number;
let portWasExplicitlySet = false; let portWasExplicitlySet = false;
@@ -144,9 +144,9 @@ export class TsView {
// CLI has highest priority // CLI has highest priority
port = cliPort; port = cliPort;
portWasExplicitlySet = true; portWasExplicitlySet = true;
} else if (npmextraConfig.port) { } else if (smartconfigConfig.port) {
// Config port specified // Config port specified
port = npmextraConfig.port; port = smartconfigConfig.port;
portWasExplicitlySet = true; portWasExplicitlySet = true;
} else { } else {
// Auto-find free port // Auto-find free port
@@ -158,11 +158,11 @@ export class TsView {
const isFree = await network.isLocalPortUnused(port); const isFree = await network.isLocalPortUnused(port);
if (!isFree) { if (!isFree) {
if (npmextraConfig.killIfBusy) { if (smartconfigConfig.killIfBusy) {
console.log(`Port ${port} is busy. Killing existing process...`); console.log(`Port ${port} is busy. Killing existing process...`);
await this.killProcessOnPort(port); await this.killProcessOnPort(port);
} else if (portWasExplicitlySet) { } else if (portWasExplicitlySet) {
throw new Error(`Port ${port} is busy. Set "killIfBusy": true in npmextra.json to auto-kill, or use a different port.`); throw new Error(`Port ${port} is busy. Set "killIfBusy": true in smartconfig.json to auto-kill, or use a different port.`);
} else { } else {
// Auto port was already free, shouldn't happen, but fallback // Auto port was already free, shouldn't happen, but fallback
port = await this.findFreePort(port + 1); port = await this.findFreePort(port + 1);
@@ -175,7 +175,7 @@ export class TsView {
console.log(`TsView server started on http://localhost:${port}`); console.log(`TsView server started on http://localhost:${port}`);
// Open browser (default: true, can be disabled via config) // Open browser (default: true, can be disabled via config)
const shouldOpenBrowser = npmextraConfig.openBrowser !== false; const shouldOpenBrowser = smartconfigConfig.openBrowser !== false;
if (shouldOpenBrowser) { if (shouldOpenBrowser) {
try { try {
await plugins.smartopen.openUrl(`http://localhost:${port}`); await plugins.smartopen.openUrl(`http://localhost:${port}`);

View File

@@ -19,7 +19,7 @@ export class TsViewCli {
this.smartcli.standardCommand().subscribe(async (argvArg) => { this.smartcli.standardCommand().subscribe(async (argvArg) => {
await this.startViewer({ await this.startViewer({
port: argvArg.port as number | undefined, port: argvArg.port as number | undefined,
s3Only: false, storageOnly: false,
mongoOnly: false, mongoOnly: false,
}); });
}); });
@@ -29,7 +29,7 @@ export class TsViewCli {
s3Command.subscribe(async (argvArg) => { s3Command.subscribe(async (argvArg) => {
await this.startViewer({ await this.startViewer({
port: argvArg.port as number | undefined, port: argvArg.port as number | undefined,
s3Only: true, storageOnly: true,
mongoOnly: false, mongoOnly: false,
}); });
}); });
@@ -39,7 +39,7 @@ export class TsViewCli {
mongoCommand.subscribe(async (argvArg) => { mongoCommand.subscribe(async (argvArg) => {
await this.startViewer({ await this.startViewer({
port: argvArg.port as number | undefined, port: argvArg.port as number | undefined,
s3Only: false, storageOnly: false,
mongoOnly: true, mongoOnly: true,
}); });
}); });
@@ -56,7 +56,7 @@ export class TsViewCli {
*/ */
private async startViewer(options: { private async startViewer(options: {
port?: number; port?: number;
s3Only: boolean; storageOnly: boolean;
mongoOnly: boolean; mongoOnly: boolean;
}): Promise<void> { }): Promise<void> {
console.log('Starting TsView...'); console.log('Starting TsView...');
@@ -67,10 +67,10 @@ export class TsViewCli {
await viewer.loadConfigFromEnv(); await viewer.loadConfigFromEnv();
// Check what's configured // Check what's configured
const hasS3 = viewer.config.hasS3(); const hasStorage = viewer.config.hasStorage();
const hasMongo = viewer.config.hasMongo(); const hasMongo = viewer.config.hasMongo();
if (!hasS3 && !hasMongo) { if (!hasStorage && !hasMongo) {
console.error('Error: No S3 or MongoDB configuration found.'); console.error('Error: No S3 or MongoDB configuration found.');
console.error('Please create .nogit/env.json with your configuration.'); console.error('Please create .nogit/env.json with your configuration.');
console.error(''); console.error('');
@@ -87,7 +87,7 @@ export class TsViewCli {
process.exit(1); process.exit(1);
} }
if (options.s3Only && !hasS3) { if (options.storageOnly && !hasStorage) {
console.error('Error: S3 configuration not found in .nogit/env.json'); console.error('Error: S3 configuration not found in .nogit/env.json');
process.exit(1); process.exit(1);
} }
@@ -98,7 +98,7 @@ export class TsViewCli {
} }
// Log what's available // Log what's available
if (hasS3) { if (hasStorage) {
console.log('S3 storage configured'); console.log('S3 storage configured');
} }
if (hasMongo) { if (hasMongo) {

View File

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

View File

@@ -0,0 +1,41 @@
import type { IStorageDataProvider } from '@design.estate/dees-catalog';
import { apiService } from '../services/index.js';
/**
* Adapter that implements IStorageDataProvider by delegating to tsview's ApiService
*/
export class TsviewS3DataProvider implements IStorageDataProvider {
async listObjects(bucket: string, prefix?: string, delimiter?: string) {
return apiService.listObjects(bucket, prefix, delimiter);
}
async getObject(bucket: string, key: string) {
return apiService.getObject(bucket, key);
}
async putObject(bucket: string, key: string, base64Content: string, contentType: string) {
return apiService.putObject(bucket, key, base64Content, contentType);
}
async deleteObject(bucket: string, key: string) {
return apiService.deleteObject(bucket, key);
}
async deletePrefix(bucket: string, prefix: string) {
return apiService.deletePrefix(bucket, prefix);
}
async getObjectUrl(bucket: string, key: string) {
return apiService.getObjectUrl(bucket, key);
}
async moveObject(bucket: string, sourceKey: string, destKey: string) {
return apiService.moveObject(bucket, sourceKey, destKey);
}
async movePrefix(bucket: string, sourcePrefix: string, destPrefix: string) {
return apiService.movePrefix(bucket, sourcePrefix, destPrefix);
}
}
export const s3DataProvider = new TsviewS3DataProvider();

View File

@@ -1,12 +1,6 @@
// Main app shell // Main app shell
export * from './tsview-app.js'; export * from './tsview-app.js';
// S3 components
export * from './tsview-s3-browser.js';
export * from './tsview-s3-columns.js';
export * from './tsview-s3-keys.js';
export * from './tsview-s3-preview.js';
// MongoDB components // MongoDB components
export * from './tsview-mongo-browser.js'; export * from './tsview-mongo-browser.js';
export * from './tsview-mongo-collections.js'; export * from './tsview-mongo-collections.js';

View File

@@ -1,10 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { changeStreamService, type IActivityEvent, type IMongoChangeEvent, type IS3ChangeEvent } from '../services/index.js'; import { changeStreamService, type IActivityEvent, type IMongoChangeEvent } from '../services/index.js';
import type { IStorageChangeEvent } from '@design.estate/dees-catalog';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
type TFilterMode = 'all' | 'mongodb' | 's3'; type TFilterMode = 'all' | 'mongodb' | 'storage';
@customElement('tsview-activity-stream') @customElement('tsview-activity-stream')
export class TsviewActivityStream extends DeesElement { export class TsviewActivityStream extends DeesElement {
@@ -426,8 +427,8 @@ export class TsviewActivityStream extends DeesElement {
const mongoEvent = event.event as IMongoChangeEvent; const mongoEvent = event.event as IMongoChangeEvent;
return `${mongoEvent.database}.${mongoEvent.collection}`; return `${mongoEvent.database}.${mongoEvent.collection}`;
} else { } else {
const s3Event = event.event as IS3ChangeEvent; const storageEvent = event.event as IStorageChangeEvent;
return s3Event.bucket; return storageEvent.bucket;
} }
} }
@@ -439,8 +440,8 @@ export class TsviewActivityStream extends DeesElement {
} }
return ''; return '';
} else { } else {
const s3Event = event.event as IS3ChangeEvent; const storageEvent = event.event as IStorageChangeEvent;
return s3Event.key; return storageEvent.key;
} }
} }
@@ -464,12 +465,12 @@ export class TsviewActivityStream extends DeesElement {
}) })
); );
} else { } else {
const s3Event = event.event as IS3ChangeEvent; const storageEvent = event.event as IStorageChangeEvent;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('navigate-to-s3', { new CustomEvent('navigate-to-s3', {
detail: { detail: {
bucket: s3Event.bucket, bucket: storageEvent.bucket,
key: s3Event.key, key: storageEvent.key,
}, },
bubbles: true, bubbles: true,
composed: true, composed: true,
@@ -529,8 +530,8 @@ export class TsviewActivityStream extends DeesElement {
MongoDB MongoDB
</button> </button>
<button <button
class="filter-tab ${this.filterMode === 's3' ? 'active' : ''}" class="filter-tab ${this.filterMode === 'storage' ? 'active' : ''}"
@click=${() => this.setFilterMode('s3')} @click=${() => this.setFilterMode('storage')}
> >
S3 S3
</button> </button>

View File

@@ -1,6 +1,8 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, changeStreamService } from '../services/index.js'; import { apiService, changeStreamService } from '../services/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
import { s3DataProvider } from '../adapters/s3-data-provider.js';
import type { IStorageChangeEvent } from '@design.estate/dees-catalog';
const { html, css, cssManager, customElement, state, DeesElement } = plugins; const { html, css, cssManager, customElement, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog; const { DeesContextmenu } = plugins.deesCatalog;
@@ -1039,7 +1041,16 @@ export class TsviewApp extends DeesElement {
return html` return html`
<div class="content-area"> <div class="content-area">
<tsview-s3-browser .bucketName=${this.selectedBucket}></tsview-s3-browser> <dees-storage-browser
.dataProvider=${s3DataProvider}
.bucketName=${this.selectedBucket}
.onChangeEvent=${(callback: (event: IStorageChangeEvent) => void) => {
const sub = changeStreamService
.getBucketChanges(this.selectedBucket)
.subscribe(callback);
return () => sub.unsubscribe();
}}
></dees-storage-browser>
</div> </div>
`; `;
} }

View File

@@ -25,7 +25,7 @@ export class TsviewMongoBrowser extends DeesElement {
private accessor stats: ICollectionStats | null = null; private accessor stats: ICollectionStats | null = null;
@state() @state()
private accessor editorWidth: number = 400; private accessor editorWidth: number = 700;
@state() @state()
private accessor isResizingEditor: boolean = false; private accessor isResizingEditor: boolean = false;
@@ -117,7 +117,7 @@ export class TsviewMongoBrowser extends DeesElement {
.content { .content {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 4px var(--editor-width, 400px); grid-template-columns: 1fr 4px var(--editor-width, 700px);
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
} }
@@ -305,7 +305,7 @@ export class TsviewMongoBrowser extends DeesElement {
const contentEl = this.shadowRoot?.querySelector('.content'); const contentEl = this.shadowRoot?.querySelector('.content');
if (!contentEl) return; if (!contentEl) return;
const containerRect = contentEl.getBoundingClientRect(); const containerRect = contentEl.getBoundingClientRect();
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 300), 700); const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 1000);
this.editorWidth = newWidth; this.editorWidth = newWidth;
}; };

View File

@@ -1,423 +0,0 @@
import * as plugins from '../plugins.js';
import { apiService, changeStreamService, type IS3ChangeEvent } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
type TViewType = 'columns' | 'keys';
@customElement('tsview-s3-browser')
export class TsviewS3Browser extends DeesElement {
@property({ type: String })
public accessor bucketName: string = '';
@state()
private accessor viewType: TViewType = 'columns';
@state()
private accessor currentPrefix: string = '';
@state()
private accessor selectedKey: string = '';
@state()
private accessor refreshKey: number = 0;
@state()
private accessor previewWidth: number = 700;
@state()
private accessor isResizingPreview: boolean = false;
@state()
private accessor recentChangeCount: number = 0;
@state()
private accessor isStreamConnected: boolean = false;
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
}
.browser-container {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
font-size: 14px;
color: #999;
}
.breadcrumb-item {
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.breadcrumb-item:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.breadcrumb-separator {
color: #555;
}
.view-toggle {
display: flex;
gap: 4px;
}
.view-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid #444;
color: #888;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.view-btn:hover {
border-color: #666;
color: #aaa;
}
.view-btn.active {
background: rgba(255, 255, 255, 0.1);
border-color: #404040;
color: #e0e0e0;
}
.content {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0;
overflow: hidden;
}
.content.has-preview {
grid-template-columns: 1fr 4px var(--preview-width, 700px);
}
.resize-divider {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background 0.2s;
}
.resize-divider:hover,
.resize-divider.active {
background: rgba(255, 255, 255, 0.2);
}
.main-view {
overflow: auto;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.preview-panel {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
margin-left: 12px;
}
@media (max-width: 1024px) {
.content,
.content.has-preview {
grid-template-columns: 1fr;
}
.preview-panel,
.resize-divider {
display: none;
}
}
.stream-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #888;
margin-left: auto;
margin-right: 12px;
}
.stream-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #888;
}
.stream-dot.connected {
background: #22c55e;
}
.change-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(245, 158, 11, 0.2);
border-radius: 4px;
font-size: 11px;
color: #f59e0b;
margin-right: 12px;
}
.change-indicator.pulse {
animation: pulse-orange 1s ease-in-out;
}
@keyframes pulse-orange {
0% { background: rgba(245, 158, 11, 0.4); }
100% { background: rgba(245, 158, 11, 0.2); }
}
`,
];
async connectedCallback() {
super.connectedCallback();
// 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) {
this.viewType = type;
}
private navigateToPrefix(prefix: string) {
this.currentPrefix = prefix;
this.selectedKey = '';
}
private handleKeySelected(e: CustomEvent) {
this.selectedKey = e.detail.key;
}
private handleNavigate(e: CustomEvent) {
this.navigateToPrefix(e.detail.prefix);
}
private handleObjectDeleted(e: CustomEvent) {
this.selectedKey = '';
// Increment refresh key to trigger re-render of child components
this.refreshKey++;
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName')) {
// Clear selection when bucket changes
this.selectedKey = '';
this.currentPrefix = '';
this.recentChangeCount = 0;
// Re-subscribe to the new bucket
this.unsubscribeFromChanges();
this.subscribeToChanges();
}
}
private async subscribeToChanges() {
if (!this.bucketName) return;
try {
// 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 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;
}
}
private unsubscribeFromChanges() {
if (this.changeSubscription) {
this.changeSubscription.unsubscribe();
this.changeSubscription = null;
}
if (this.bucketName) {
changeStreamService.unsubscribeFromBucket(this.bucketName, this.currentPrefix || undefined);
}
this.isStreamConnected = false;
}
private handleChange(event: IS3ChangeEvent) {
console.log('[S3Browser] Received change:', event);
this.recentChangeCount++;
// Trigger refresh of child components
this.refreshKey++;
}
private startPreviewResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizingPreview = true;
document.addEventListener('mousemove', this.handlePreviewResize);
document.addEventListener('mouseup', this.endPreviewResize);
};
private handlePreviewResize = (e: MouseEvent) => {
if (!this.isResizingPreview) return;
const contentEl = this.shadowRoot?.querySelector('.content');
if (!contentEl) return;
const containerRect = contentEl.getBoundingClientRect();
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 1000);
this.previewWidth = newWidth;
};
private endPreviewResize = () => {
this.isResizingPreview = false;
document.removeEventListener('mousemove', this.handlePreviewResize);
document.removeEventListener('mouseup', this.endPreviewResize);
};
render() {
const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean)
: [];
return html`
<div class="browser-container">
<div class="toolbar">
<div class="breadcrumb">
<span
class="breadcrumb-item"
@click=${() => this.navigateToPrefix('')}
>
${this.bucketName}
</span>
${breadcrumbParts.map((part, index) => {
const prefix = breadcrumbParts.slice(0, index + 1).join('/') + '/';
return html`
<span class="breadcrumb-separator">/</span>
<span
class="breadcrumb-item"
@click=${() => this.navigateToPrefix(prefix)}
>
${part}
</span>
`;
})}
</div>
<div class="stream-status">
<span class="stream-dot ${this.isStreamConnected ? 'connected' : ''}"></span>
${this.isStreamConnected ? 'Live' : 'Offline'}
</div>
${this.recentChangeCount > 0
? html`
<div class="change-indicator pulse">
${this.recentChangeCount} change${this.recentChangeCount > 1 ? 's' : ''}
</div>
`
: ''}
<div class="view-toggle">
<button
class="view-btn ${this.viewType === 'columns' ? 'active' : ''}"
@click=${() => this.setViewType('columns')}
>
Columns
</button>
<button
class="view-btn ${this.viewType === 'keys' ? 'active' : ''}"
@click=${() => this.setViewType('keys')}
>
List
</button>
</div>
</div>
<div class="content ${this.selectedKey ? 'has-preview' : ''}" style="--preview-width: ${this.previewWidth}px">
<div class="main-view">
${this.viewType === 'columns'
? html`
<tsview-s3-columns
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></tsview-s3-columns>
`
: html`
<tsview-s3-keys
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></tsview-s3-keys>
`}
</div>
${this.selectedKey
? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel">
<tsview-s3-preview
.bucketName=${this.bucketName}
.objectKey=${this.selectedKey}
@object-deleted=${this.handleObjectDeleted}
></tsview-s3-preview>
</div>
`
: ''}
</div>
</div>
`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,600 +0,0 @@
import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js';
import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
@customElement('tsview-s3-keys')
export class TsviewS3Keys extends DeesElement {
@property({ type: String })
public accessor bucketName: string = '';
@property({ type: String })
public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state()
private accessor allKeys: IS3Object[] = [];
@state()
private accessor prefixes: string[] = [];
@state()
private accessor loading: boolean = false;
@state()
private accessor selectedKey: string = '';
@state()
private accessor filterText: string = '';
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
overflow: hidden;
}
.keys-container {
display: flex;
flex-direction: column;
height: 100%;
}
.filter-bar {
padding: 12px;
border-bottom: 1px solid #333;
}
.filter-input {
width: 100%;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 14px;
}
.filter-input:focus {
outline: none;
border-color: #404040;
}
.filter-input::placeholder {
color: #666;
}
.keys-list {
flex: 1;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
position: sticky;
top: 0;
background: #1a1a1a;
z-index: 1;
}
th {
text-align: left;
padding: 10px 12px;
font-size: 12px;
font-weight: 500;
color: #666;
text-transform: uppercase;
border-bottom: 1px solid #333;
}
td {
padding: 8px 12px;
font-size: 13px;
border-bottom: 1px solid #2a2a3e;
}
tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
tr.selected td {
background: rgba(255, 255, 255, 0.08);
}
.key-cell {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.key-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.folder-icon {
color: #fbbf24;
}
.key-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size-cell {
color: #888;
font-variant-numeric: tabular-nums;
}
.empty-state {
padding: 32px;
text-align: center;
color: #666;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #1e1e1e;
border-radius: 12px;
padding: 24px;
min-width: 400px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.dialog-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dialog-btn-cancel {
background: transparent;
border: 1px solid #444;
color: #aaa;
}
.dialog-btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.dialog-btn-create {
background: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`,
];
async connectedCallback() {
super.connectedCallback();
await this.loadObjects();
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) {
this.loadObjects();
}
}
private async loadObjects() {
this.loading = true;
try {
const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/');
this.allKeys = result.objects;
this.prefixes = result.prefixes;
} catch (err) {
console.error('Error loading objects:', err);
this.allKeys = [];
this.prefixes = [];
}
this.loading = false;
}
private handleFilterInput(e: Event) {
this.filterText = (e.target as HTMLInputElement).value;
}
private selectKey(key: string, isFolder: boolean) {
this.selectedKey = key;
if (isFolder) {
this.dispatchEvent(
new CustomEvent('navigate', {
detail: { prefix: key },
bubbles: true,
composed: true,
})
);
} else {
this.dispatchEvent(
new CustomEvent('key-selected', {
detail: { key },
bubbles: true,
composed: true,
})
);
}
}
private get filteredItems() {
const filter = this.filterText.toLowerCase();
const folders = this.prefixes
.filter((p) => !filter || getFileName(p).toLowerCase().includes(filter))
.map((p) => ({ key: p, isFolder: true, size: undefined }));
const files = this.allKeys
.filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter))
.map((o) => ({ key: o.key, isFolder: false, size: o.size }));
return [...folders, ...files];
}
private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) {
event.preventDefault();
if (isFolder) {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Open',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectKey(key, true);
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', key),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', key),
},
{ divider: true },
{
name: 'Delete Folder',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, key);
if (success) {
await this.loadObjects();
}
}
},
},
]);
} else {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Preview',
iconName: 'lucide:eye',
action: async () => {
this.selectKey(key, false);
},
},
{
name: 'Download',
iconName: 'lucide:download',
action: async () => {
const url = await apiService.getObjectUrl(this.bucketName, key);
const link = document.createElement('a');
link.href = url;
link.download = getFileName(key);
link.click();
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete file "${getFileName(key)}"?`)) {
const success = await apiService.deleteObject(this.bucketName, key);
if (success) {
await this.loadObjects();
}
}
},
},
]);
}
}
private handleEmptySpaceContextMenu(event: MouseEvent) {
// Only trigger if clicking on the container itself, not on items
if ((event.target as HTMLElement).closest('tr')) return;
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', this.currentPrefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', this.currentPrefix),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.loadObjects();
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() {
return html`
<div class="keys-container">
<div class="filter-bar">
<input
type="text"
class="filter-input"
placeholder="Filter files..."
.value=${this.filterText}
@input=${this.handleFilterInput}
/>
</div>
<div class="keys-list" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e)}>
${this.loading
? html`<div class="empty-state">Loading...</div>`
: this.filteredItems.length === 0
? html`<div class="empty-state">No objects found</div>`
: html`
<table>
<thead>
<tr>
<th>Name</th>
<th style="width: 100px;">Size</th>
</tr>
</thead>
<tbody>
${this.filteredItems.map(
(item) => html`
<tr
class="${this.selectedKey === item.key ? 'selected' : ''}"
@click=${() => this.selectKey(item.key, item.isFolder)}
@contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)}
>
<td>
<div class="key-cell">
${item.isFolder
? html`
<svg class="key-icon folder-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>
`
: html`
<svg class="key-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</svg>
`}
<span class="key-name">${getFileName(item.key)}</span>
</div>
</td>
<td class="size-cell">
${item.isFolder ? '-' : formatSize(item.size)}
</td>
</tr>
`
)}
</tbody>
</table>
`}
</div>
</div>
${this.renderCreateDialog()}
`;
}
}

View File

@@ -1,538 +0,0 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@customElement('tsview-s3-preview')
export class TsviewS3Preview extends DeesElement {
@property({ type: String })
public accessor bucketName: string = '';
@property({ type: String })
public accessor objectKey: string = '';
@state()
private accessor loading: boolean = false;
@state()
private accessor saving: boolean = false;
@state()
private accessor content: string = '';
@state()
private accessor originalTextContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state()
private accessor editing: boolean = false;
@state()
private accessor contentType: string = '';
@state()
private accessor size: number = 0;
@state()
private accessor lastModified: string = '';
@state()
private accessor error: string = '';
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
}
.preview-container {
display: flex;
flex-direction: column;
height: 100%;
}
.preview-header {
padding: 12px;
border-bottom: 1px solid #333;
}
.preview-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
word-break: break-all;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: #888;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.preview-content {
flex: 1;
overflow: hidden;
}
.preview-content dees-preview {
width: 100%;
height: 100%;
}
.preview-content.code-editor {
padding: 0;
overflow: hidden;
}
.preview-content.code-editor dees-input-code {
height: 100%;
}
.preview-actions {
padding: 12px;
border-top: 1px solid #333;
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #404040;
color: #e0e0e0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.action-btn.danger {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
color: #f87171;
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.action-btn.primary {
background: rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
color: #60a5fa;
}
.action-btn.primary:hover {
background: rgba(59, 130, 246, 0.4);
}
.action-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.05);
border-color: #555;
color: #aaa;
}
.action-btn.secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.unsaved-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 4px;
font-size: 12px;
color: #fbbf24;
}
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fbbf24;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
text-align: center;
padding: 24px;
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
}
.error-state {
padding: 16px;
color: #f87171;
text-align: center;
}
`,
];
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('objectKey') || changedProperties.has('bucketName')) {
if (this.objectKey) {
this.loadObject();
} else {
this.content = '';
this.contentType = '';
this.error = '';
this.originalTextContent = '';
this.hasChanges = false;
this.editing = false;
}
}
}
private async loadObject() {
if (!this.objectKey || !this.bucketName) return;
this.loading = true;
this.error = '';
this.hasChanges = false;
this.editing = false;
try {
const result = await apiService.getObject(this.bucketName, this.objectKey);
if (!result) {
this.error = 'Object not found';
this.loading = false;
return;
}
this.content = result.content || '';
this.contentType = result.contentType || '';
this.size = result.size || 0;
this.lastModified = result.lastModified || '';
// For text files, decode and store original content
if (this.isText()) {
this.originalTextContent = this.getTextContent();
}
} catch (err) {
console.error('Error loading object:', err);
this.error = 'Failed to load object';
}
this.loading = false;
}
private formatDate(dateStr: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
}
private isImage(): boolean {
return this.contentType.startsWith('image/');
}
private isText(): boolean {
return (
this.contentType.startsWith('text/') ||
this.contentType === 'application/json' ||
this.contentType === 'application/xml' ||
this.contentType === 'application/javascript'
);
}
private getTextContent(): string {
try {
// Properly decode base64 to UTF-8 text
const binaryString = atob(this.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
} catch {
return 'Unable to decode content';
}
}
private async handleDownload() {
try {
const blob = new Blob([Uint8Array.from(atob(this.content), (c) => c.charCodeAt(0))], {
type: this.contentType,
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFileName(this.objectKey);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading:', err);
}
}
private async handleDelete() {
if (!confirm(`Delete "${getFileName(this.objectKey)}"?`)) return;
try {
await apiService.deleteObject(this.bucketName, this.objectKey);
this.dispatchEvent(
new CustomEvent('object-deleted', {
detail: { key: this.objectKey },
bubbles: true,
composed: true,
})
);
} catch (err) {
console.error('Error deleting object:', err);
}
}
private getLanguage(): string {
const ext = this.objectKey.split('.').pop()?.toLowerCase() || '';
const languageMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
json: 'json',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
sass: 'scss',
less: 'less',
md: 'markdown',
markdown: 'markdown',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
dockerfile: 'dockerfile',
txt: 'plaintext',
};
return languageMap[ext] || 'plaintext';
}
private handleContentChange(event: CustomEvent) {
const newValue = event.detail as string;
this.hasChanges = newValue !== this.originalTextContent;
}
private 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() {
if (!this.hasChanges || this.saving) return;
this.saving = true;
try {
// Get current content from the editor
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
const currentContent = codeEditor?.value ?? '';
// Encode the text content to base64
const encoder = new TextEncoder();
const bytes = encoder.encode(currentContent);
const base64Content = btoa(String.fromCharCode(...bytes));
const success = await apiService.putObject(
this.bucketName,
this.objectKey,
base64Content,
this.contentType
);
if (success) {
this.originalTextContent = currentContent;
this.hasChanges = false;
this.editing = false;
// Update the stored content as well
this.content = base64Content;
}
} catch (err) {
console.error('Error saving object:', err);
}
this.saving = false;
}
render() {
if (!this.objectKey) {
return html`
<div class="preview-container">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Select a file to preview</p>
</div>
</div>
`;
}
if (this.loading) {
return html`
<div class="preview-container">
<div class="loading-state">Loading...</div>
</div>
`;
}
if (this.error) {
return html`
<div class="preview-container">
<div class="error-state">${this.error}</div>
</div>
`;
}
return html`
<div class="preview-container">
<div class="preview-header">
<div class="preview-title">${getFileName(this.objectKey)}</div>
<div class="preview-meta">
<span class="meta-item">${this.contentType}</span>
<span class="meta-item">${formatSize(this.size)}</span>
<span class="meta-item">${this.formatDate(this.lastModified)}</span>
${this.hasChanges ? html`
<span class="unsaved-indicator">
<span class="unsaved-dot"></span>
Unsaved changes
</span>
` : ''}
</div>
</div>
<div class="preview-content ${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-preview
.textContent=${this.originalTextContent}
.filename=${getFileName(this.objectKey)}
.language=${this.getLanguage()}
.showToolbar=${true}
.showFilename=${false}
></dees-preview>
`
: html`
<dees-preview
.base64=${this.content}
.mimeType=${this.contentType}
.filename=${getFileName(this.objectKey)}
.showToolbar=${true}
.showFilename=${false}
></dees-preview>
`
}
</div>
<div class="preview-actions">
${this.editing
? html`
<button class="action-btn secondary" @click=${this.handleDiscard}>
${this.hasChanges ? 'Discard' : 'Cancel'}
</button>
<button
class="action-btn primary"
@click=${this.handleSave}
?disabled=${this.saving || !this.hasChanges}
>
${this.saving ? 'Saving...' : 'Save'}
</button>
`
: html`
${this.isText()
? html`<button class="action-btn" @click=${this.handleEdit}>Edit</button>`
: ''}
<button class="action-btn" @click=${this.handleDownload}>Download</button>
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
`
}
</div>
</div>
`;
}
}

View File

@@ -1,13 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { IStorageObject } from '@design.estate/dees-catalog';
// Import interfaces from shared types export type { IStorageObject };
// Note: In bundled form these are inlined
export interface IS3Object {
key: string;
size?: number;
lastModified?: string;
isPrefix?: boolean;
}
export interface IMongoDatabase { export interface IMongoDatabase {
name: string; name: string;
@@ -100,7 +93,7 @@ export class ApiService {
bucketName: string, bucketName: string,
prefix?: string, prefix?: string,
delimiter?: string delimiter?: string
): Promise<{ objects: IS3Object[]; prefixes: string[] }> { ): Promise<{ objects: IStorageObject[]; prefixes: string[] }> {
return this.request('listObjects', { bucketName, prefix, delimiter }); return this.request('listObjects', { bucketName, prefix, delimiter });
} }
@@ -168,6 +161,28 @@ export class ApiService {
return result.success; 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 // MongoDB API Methods
// =========================================== // ===========================================

View File

@@ -1,4 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { IStorageChangeEvent } from '@design.estate/dees-catalog';
export type { IStorageChangeEvent };
/** /**
* MongoDB change event * MongoDB change event
@@ -16,25 +18,13 @@ export interface IMongoChangeEvent {
timestamp: string; timestamp: string;
} }
/**
* S3 change event
*/
export interface IS3ChangeEvent {
type: 'add' | 'modify' | 'delete';
key: string;
size?: number;
etag?: string;
lastModified?: Date;
bucket: string;
}
/** /**
* Combined activity event * Combined activity event
*/ */
export interface IActivityEvent { export interface IActivityEvent {
id: string; id: string;
source: 'mongodb' | 's3'; source: 'mongodb' | 'storage';
event: IMongoChangeEvent | IS3ChangeEvent; event: IMongoChangeEvent | IStorageChangeEvent;
timestamp: string; timestamp: string;
} }
@@ -42,7 +32,7 @@ export interface IActivityEvent {
* Subscription info tracked by the service * Subscription info tracked by the service
*/ */
interface ISubscription { interface ISubscription {
type: 'mongo' | 's3' | 'activity'; type: 'mongo' | 'storage' | 'activity';
key: string; // "db/collection" or "bucket/prefix" or "activity" key: string; // "db/collection" or "bucket/prefix" or "activity"
subscriptionId: string; subscriptionId: string;
} }
@@ -69,7 +59,7 @@ export class ChangeStreamService {
// RxJS Subjects for UI components // RxJS Subjects for UI components
public readonly mongoChanges$ = new plugins.smartrx.rxjs.Subject<IMongoChangeEvent>(); public readonly mongoChanges$ = new plugins.smartrx.rxjs.Subject<IMongoChangeEvent>();
public readonly s3Changes$ = new plugins.smartrx.rxjs.Subject<IS3ChangeEvent>(); public readonly storageChanges$ = new plugins.smartrx.rxjs.Subject<IStorageChangeEvent>();
public readonly activityEvents$ = new plugins.smartrx.rxjs.Subject<IActivityEvent>(); public readonly activityEvents$ = new plugins.smartrx.rxjs.Subject<IActivityEvent>();
public readonly connectionStatus$ = new plugins.smartrx.rxjs.ReplaySubject<'connected' | 'disconnected' | 'connecting'>(1); public readonly connectionStatus$ = new plugins.smartrx.rxjs.ReplaySubject<'connected' | 'disconnected' | 'connecting'>(1);
@@ -193,8 +183,8 @@ export class ChangeStreamService {
router.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>( new plugins.typedrequest.TypedHandler<any>(
'pushS3Change', 'pushS3Change',
async (data: { event: IS3ChangeEvent }) => { async (data: { event: IStorageChangeEvent }) => {
this.s3Changes$.next(data.event); this.storageChanges$.next(data.event);
return { received: true }; return { received: true };
} }
) )
@@ -540,8 +530,8 @@ export class ChangeStreamService {
/** /**
* Get S3 changes as an Observable * Get S3 changes as an Observable
*/ */
public getS3Changes(): plugins.smartrx.rxjs.Observable<IS3ChangeEvent> { public getStorageChanges(): plugins.smartrx.rxjs.Observable<IStorageChangeEvent> {
return this.s3Changes$.asObservable(); return this.storageChanges$.asObservable();
} }
/** /**
@@ -565,8 +555,8 @@ export class ChangeStreamService {
/** /**
* Get filtered S3 changes for a specific bucket/prefix * Get filtered S3 changes for a specific bucket/prefix
*/ */
public getBucketChanges(bucket: string, prefix?: string): plugins.smartrx.rxjs.Observable<IS3ChangeEvent> { public getBucketChanges(bucket: string, prefix?: string): plugins.smartrx.rxjs.Observable<IStorageChangeEvent> {
return this.s3Changes$.pipe( return this.storageChanges$.pipe(
plugins.smartrx.rxjs.ops.filter((event) => { plugins.smartrx.rxjs.ops.filter((event) => {
if (event.bucket !== bucket) return false; if (event.bucket !== bucket) return false;
if (prefix && !event.key.startsWith(prefix)) return false; if (prefix && !event.key.startsWith(prefix)) return false;

View File

@@ -1 +1,2 @@
export * from './formatters.js'; export * from './formatters.js';
export * from './move-validator.js';

View 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) : '';
}

View File

@@ -4,9 +4,7 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true
"baseUrl": ".",
"paths": {}
}, },
"exclude": ["dist_*/**/*.d.ts"] "exclude": ["dist_*/**/*.d.ts"]
} }