feat(tsview): add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config

This commit is contained in:
2026-01-25 11:02:53 +00:00
parent cf07f8cad9
commit afc32f3578
52 changed files with 1078 additions and 237 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

42
changelog.md Normal file
View File

@@ -0,0 +1,42 @@
# Changelog
## 2026-01-25 - 1.1.0 - feat(tsview)
add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config
- Add MongoDB management handlers: createDatabase, dropDatabase, dropCollection (ts/api/handlers.mongodb.ts)
- Enhance S3 handlers: logging, listBuckets improvements, expanded content-type map (ts/api/handlers.s3.ts)
- Add tswatch support and watcher config in npmextra.json and package.json (scripts: watch, startTs); add @git.zone/tswatch dep
- Add web exports for styles and utilities (ts_web/styles/index.ts, ts_web/utilities/index.ts) and minor UI assets (.playwright-mcp images)
- Update test to use toEqual and small test cleanup (test/test.tsview.ts)
- Update package.json: add startTs/watch scripts and bump @push.rocks/smartbucket patch
- Add release/registry and project metadata in npmextra.json for publishing
## 2026-01-23 - 1.0.0 - initial release: column view UI, S3 integration, and API fixes
Initial public release introducing the new column-based UI with resizable columns and horizontal navigation, plus backend fixes for S3 bucket listing and API endpoint handling.
- feat: Add resizable columns and horizontal scrolling
- Columns can be resized by dragging the border between them
- Column widths persist during navigation (150px min, 500px max)
- Container scrolls horizontally when columns exceed available space
- Auto-scrolls to show newly opened columns
- Resize handle highlights on hover/active state
- fix: Column view navigation and state preservation
- Preserve previous columns during navigation; only reset columns when the bucket changes, not when the prefix changes
- Column navigation now expands horizontally instead of resetting the column stack
- Remove navigate event dispatch from column folder selection to preserve column state
- fix: Duplicate app instance rendering
- Prevent duplicate tsview-app rendering by checking if the element already exists in the DOM
- fix / feat: S3 bucket listing
- Implement listBuckets to actually query S3 (was previously hardcoded to return an empty array)
- Add @aws-sdk/client-s3 dependency to perform direct S3 operations and return real bucket names
- fix: Resolve API 404s and improve dev caching
- Update ApiService baseUrl to include the /typedrequest path expected by TypedServer
- Add noCache option to ViewServer to prevent client caching issues during development
- Bump @api.global/typedserver to v8.3.0 (includes noCache feature)
- chore: initial project scaffold
- Initial commit and project scaffolding (summary)

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 Lossless GmbH (https://lossless.com) Copyright (c) 2024 Task Venture Capital GmbH (https://task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -6,18 +6,53 @@
"to": "./ts/bundled_ui.ts", "to": "./ts/bundled_ui.ts",
"outputMode": "base64ts", "outputMode": "base64ts",
"bundler": "esbuild", "bundler": "esbuild",
"includeFiles": ["html/**/*"] "includeFiles": [
"html/**/*"
]
}
]
},
"@git.zone/tswatch": {
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./cli.ts.js"],
"command": "tsbuild && pnpm run startTs",
"restart": true
},
{
"name": "frontend",
"watch": ["./ts_web/**/*", "./html/**/*"],
"command": "tsbundle",
"restart": false
} }
] ]
}, },
"@git.zone/tsview": { "@git.zone/tsview": {
"port": 3010, "port": 3010,
"openBrowser": true "killIfBusy": true,
"openBrowser": false
}, },
"@git.zone/cli": { "@git.zone/cli": {
"services": [ "services": [
"mongodb", "mongodb",
"minio" "minio"
] ],
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
},
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "git.zone",
"gitrepo": "tsview",
"description": "a dev viewer for mongodb and s3",
"npmPackagename": "@git.zone/tsview",
"license": "MIT"
} }
},
"@ship.zone/szci": {}
} }

View File

@@ -11,7 +11,9 @@
"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",
"bundle": "tsbundle" "bundle": "tsbundle",
"startTs": "node cli.ts.js",
"watch": "tswatch"
}, },
"bin": { "bin": {
"tsview": "cli.js" "tsview": "cli.js"
@@ -21,6 +23,7 @@
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.6", "@git.zone/tstest": "^3.1.6",
"@git.zone/tswatch": "3.0.1",
"@types/node": "^25.0.10" "@types/node": "^25.0.10"
}, },
"dependencies": { "dependencies": {
@@ -32,7 +35,7 @@
"@design.estate/dees-element": "^2.1.5", "@design.estate/dees-element": "^2.1.5",
"@push.rocks/early": "^4.0.4", "@push.rocks/early": "^4.0.4",
"@push.rocks/npmextra": "^5.3.3", "@push.rocks/npmextra": "^5.3.3",
"@push.rocks/smartbucket": "^4.3.0", "@push.rocks/smartbucket": "^4.3.1",
"@push.rocks/smartcli": "^4.0.20", "@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfile": "^13.1.2",

46
pnpm-lock.yaml generated
View File

@@ -33,8 +33,8 @@ importers:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.3.3 version: 5.3.3
'@push.rocks/smartbucket': '@push.rocks/smartbucket':
specifier: ^4.3.0 specifier: ^4.3.1
version: 4.3.0 version: 4.3.1
'@push.rocks/smartcli': '@push.rocks/smartcli':
specifier: ^4.0.20 specifier: ^4.0.20
version: 4.0.20 version: 4.0.20
@@ -75,6 +75,9 @@ importers:
'@git.zone/tstest': '@git.zone/tstest':
specifier: ^3.1.6 specifier: ^3.1.6
version: 3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3) version: 3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
'@git.zone/tswatch':
specifier: 3.0.1
version: 3.0.1(@tiptap/pm@2.27.2)
'@types/node': '@types/node':
specifier: ^25.0.10 specifier: ^25.0.10
version: 25.0.10 version: 25.0.10
@@ -536,6 +539,10 @@ packages:
resolution: {integrity: sha512-xRGc6wO4rJ6mohPCMIBDRH+oNjiIvX6Jeo8v/Y5o5VyKSHFmqol7FCKSBrojMcqgBpESnLHFPJAAOmT9W3JV8Q==} resolution: {integrity: sha512-xRGc6wO4rJ6mohPCMIBDRH+oNjiIvX6Jeo8v/Y5o5VyKSHFmqol7FCKSBrojMcqgBpESnLHFPJAAOmT9W3JV8Q==}
hasBin: true hasBin: true
'@git.zone/tswatch@3.0.1':
resolution: {integrity: sha512-vrAkKM5ff/e1BLNkrIRXnTIkMyjl/uW49c1cYaw2nYGloM6/wT1FSwYjwh6BcDkHIYMnzS30SOy9jSYRptW/iw==}
hasBin: true
'@happy-dom/global-registrator@15.11.7': '@happy-dom/global-registrator@15.11.7':
resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==} resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -813,8 +820,8 @@ packages:
'@push.rocks/smartbucket@3.3.10': '@push.rocks/smartbucket@3.3.10':
resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==} resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==}
'@push.rocks/smartbucket@4.3.0': '@push.rocks/smartbucket@4.3.1':
resolution: {integrity: sha512-4nstzEduCKou4R5ekKH6kUjDZXWfrtjA1hIQ4MJmTbtncmm2+4+ixjaFThS2nS8Aa+fHcBgOtKkBv8wTsgvK/Q==} resolution: {integrity: sha512-fMA8w98/E+usaaLkLm6wDj1XSpR0shTtG8AxTdwWIlH1YemQj/aCf4wReezDxUFVoUpC3HMzzV2RTFtQvHndeQ==}
'@push.rocks/smartbuffer@3.0.5': '@push.rocks/smartbuffer@3.0.5':
resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==} resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==}
@@ -5001,6 +5008,33 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - vue
'@git.zone/tswatch@3.0.1(@tiptap/pm@2.27.2)':
dependencies:
'@api.global/typedserver': 8.3.0(@tiptap/pm@2.27.2)
'@git.zone/tsbundle': 2.8.3
'@git.zone/tsrun': 2.0.1
'@push.rocks/early': 4.0.4
'@push.rocks/lik': 6.2.2
'@push.rocks/npmextra': 5.3.3
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartshell': 3.3.0
'@push.rocks/smartwatch': 6.3.0
'@push.rocks/taskbuffer': 3.5.0
transitivePeerDependencies:
- '@nuxt/kit'
- '@swc/helpers'
- '@tiptap/pm'
- bufferutil
- react
- supports-color
- utf-8-validate
- vue
'@happy-dom/global-registrator@15.11.7': '@happy-dom/global-registrator@15.11.7':
dependencies: dependencies:
happy-dom: 15.11.7 happy-dom: 15.11.7
@@ -5485,7 +5519,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@push.rocks/smartbucket@4.3.0': '@push.rocks/smartbucket@4.3.1':
dependencies: dependencies:
'@aws-sdk/client-s3': 3.975.0 '@aws-sdk/client-s3': 3.975.0
'@push.rocks/smartmime': 2.0.4 '@push.rocks/smartmime': 2.0.4
@@ -5941,7 +5975,7 @@ snapshots:
'@push.rocks/smarts3@3.0.3': '@push.rocks/smarts3@3.0.3':
dependencies: dependencies:
'@push.rocks/smartbucket': 4.3.0 '@push.rocks/smartbucket': 4.3.1
'@push.rocks/smartfs': 1.3.1 '@push.rocks/smartfs': 1.3.1
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartxml': 2.0.0 '@push.rocks/smartxml': 2.0.0

View File

@@ -25,6 +25,12 @@ tsview is a CLI tool for viewing S3 and MongoDB data through a web UI.
- Run `pnpm build` to compile TypeScript and bundle web UI - Run `pnpm build` to compile TypeScript and bundle web UI
- UI is bundled from `ts_web/` to `ts/bundled_ui.ts` as base64 - UI is bundled from `ts_web/` to `ts/bundled_ui.ts` as base64
### Web UI Structure
- `ts_web/elements/` - Web components (LitElement-based)
- `ts_web/services/` - API service for backend communication
- `ts_web/utilities/` - Shared formatting functions (formatSize, formatCount, getFileName)
- `ts_web/styles/` - Shared CSS custom properties (themeStyles)
### TypedRequest Pattern ### TypedRequest Pattern
```typescript ```typescript
// Interface definition // Interface definition

240
readme.md
View File

@@ -1,36 +1,56 @@
# @git.zone/tsview # @git.zone/tsview
A CLI tool for viewing S3 and MongoDB data with a web UI. A powerful developer tool for browsing and managing S3-compatible storage and MongoDB databases through a sleek web UI. Built with TypeScript, designed for developers who need quick, visual access to their data stores during development. 🚀
## Installation ## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Install
```bash ```bash
# Global installation (recommended for CLI usage)
npm install -g @git.zone/tsview npm install -g @git.zone/tsview
# or # or
pnpm add -g @git.zone/tsview pnpm add -g @git.zone/tsview
# Local installation (for programmatic usage)
npm install @git.zone/tsview
# or
pnpm add @git.zone/tsview
``` ```
## Usage ## Features ✨
### CLI ### 🗄️ S3 Storage Browser
- **Column View Navigation** - Mac Finder-style interface with resizable columns for intuitive file browsing
- **List View** - Traditional key-based view with hierarchical navigation
- **Real-time Preview** - View images, JSON, text files, and more directly in the browser
- **Bucket Management** - Create, delete, and switch between buckets
- **File Operations** - Upload, download, delete objects with ease
- **Smart Content Type Detection** - Automatic content type recognition for 20+ file types
- **Breadcrumb Navigation** - Easy path traversal with clickable breadcrumbs
```bash ### 🍃 MongoDB Browser
# Start viewer (auto-finds free port from 3010+) - **Database Explorer** - Hierarchical navigation through databases and collections
tsview - **Document Viewer** - Paginated table view with sorting and filtering
- **Document Editor** - Full CRUD operations with JSON syntax highlighting
- **Index Management** - View, create, and drop indexes
- **Aggregation Pipeline** - Run custom aggregation queries (coming soon)
- **Collection Stats** - View document counts, sizes, and storage metrics
- **Server Status** - Monitor connection info and server health
# Force specific port ### 🎨 Modern Web UI
tsview --port 3000 - 🌙 Dark theme designed for developer comfort
- 📱 Responsive layout with resizable panels
- ⌨️ Keyboard-friendly navigation
- 🔌 Zero external runtime dependencies in the browser
# S3 viewer only ## Quick Start 🚀
tsview s3
# MongoDB viewer only ### 1. Configure Your Connection
tsview mongo
```
### Configuration Create a `.nogit/env.json` file in your project root:
tsview reads configuration from `.nogit/env.json` (the same format used by `gitzone service`):
```json ```json
{ {
@@ -44,17 +64,67 @@ tsview reads configuration from `.nogit/env.json` (the same format used by `gitz
} }
``` ```
### Programmatic API ### 2. Launch the Viewer
```bash
tsview
```
That's it! 🎉 Your browser will automatically open to the viewer interface.
## CLI Usage
```bash
# Start viewer with auto-detected port (starts from 3010)
tsview
# Force a specific port
tsview --port 3000
# S3 browser only
tsview s3
# MongoDB browser only
tsview mongo
# or
tsview mongodb
```
## Configuration via npmextra.json
For project-level configuration, add a `@git.zone/tsview` section to your `npmextra.json`:
```json
{
"@git.zone/tsview": {
"port": 3015,
"killIfBusy": true,
"openBrowser": false
}
}
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `port` | `number` | auto | Fixed port to use (auto-finds from 3010 if not set) |
| `killIfBusy` | `boolean` | `false` | Kill existing process if port is busy |
| `openBrowser` | `boolean` | `true` | Automatically open browser on start |
**Priority order:** CLI `--port` flag > `npmextra.json` config > auto-detect
## Programmatic API
Use tsview as a library in your own tools:
```typescript ```typescript
import { TsView } from '@git.zone/tsview'; import { TsView } from '@git.zone/tsview';
const viewer = new TsView(); const viewer = new TsView();
// Option 1: Load from env.json (gitzone service) // Option 1: Load from .nogit/env.json (gitzone service format)
await viewer.loadConfigFromEnv(); await viewer.loadConfigFromEnv();
// Option 2: Custom local config (MinIO + local MongoDB) // Option 2: Configure programmatically for local development
viewer.setS3Config({ viewer.setS3Config({
endpoint: 'localhost', endpoint: 'localhost',
port: 9000, port: 9000,
@@ -63,61 +133,145 @@ viewer.setS3Config({
useSsl: false useSsl: false
}); });
// Option 3: Cloud config (AWS S3 + MongoDB Atlas) viewer.setMongoConfig({
mongoDbUrl: 'mongodb://localhost:27017',
mongoDbName: 'mydb'
});
// Option 3: Configure for cloud services
viewer.setS3Config({ viewer.setS3Config({
endpoint: 's3.amazonaws.com', endpoint: 's3.amazonaws.com',
accessKey: 'AKIAXXXXXXX', accessKey: 'AKIAXXXXXXX',
accessSecret: 'secret', accessSecret: 'your-secret-key',
useSsl: true, useSsl: true,
region: 'us-east-1' region: 'us-east-1'
}); });
viewer.setMongoConfig({ viewer.setMongoConfig({
mongoDbUrl: 'mongodb+srv://user:pass@cluster.mongodb.net', mongoDbUrl: 'mongodb+srv://user:pass@cluster.mongodb.net',
mongoDbName: 'mydb' mongoDbName: 'production'
}); });
// Start on auto-found port // Start the server
const port = await viewer.start(); const port = await viewer.start();
console.log(`Viewer running on http://localhost:${port}`); console.log(`Viewer running on http://localhost:${port}`);
// Or force specific port // Or specify a port
await viewer.start(3500); await viewer.start(3500);
// Stop when done // Graceful shutdown
await viewer.stop(); await viewer.stop();
``` ```
## Features ## Environment Variables
### S3 Browser The following environment variables are supported in `.nogit/env.json`:
- **Column View**: Mac Finder-style navigation with horizontal columns
- **List View**: Flat list of all keys with filtering
- **Preview Panel**: View images, text, and JSON files
- **Operations**: Download, delete files
### MongoDB Browser ### S3 Configuration
- **Database/Collection Navigation**: Hierarchical sidebar | Variable | Description |
- **Documents Table**: Paginated view with filtering |----------|-------------|
- **Document Editor**: Edit documents with JSON syntax highlighting | `S3_ENDPOINT` | S3 server hostname |
- **Index Management**: View, create, and drop indexes | `S3_PORT` | S3 server port (optional) |
| `S3_ACCESSKEY` | Access key ID |
| `S3_SECRETKEY` | Secret access key |
| `S3_USESSL` | Use HTTPS (`true`/`false`) |
### MongoDB Configuration
| Variable | Description |
|----------|-------------|
| `MONGODB_URL` | Full MongoDB connection string |
| `MONGODB_NAME` | Default database name |
Or use individual MongoDB variables:
| Variable | Description |
|----------|-------------|
| `MONGODB_HOST` | MongoDB hostname |
| `MONGODB_PORT` | MongoDB port |
| `MONGODB_USER` | Username |
| `MONGODB_PASS` | Password |
| `MONGODB_NAME` | Database name |
## Supported S3 Providers
tsview works with any S3-compatible storage:
| Provider | Status |
|----------|--------|
| **MinIO** | ✅ Perfect for local development |
| **AWS S3** | ✅ Amazon's object storage |
| **DigitalOcean Spaces** | ✅ Simple object storage |
| **Backblaze B2** | ✅ S3-compatible API |
| **Cloudflare R2** | ✅ Zero egress fees |
| **Wasabi** | ✅ Hot cloud storage |
| **Self-hosted** | ✅ Any S3-compatible server |
## Supported File Types for Preview
| Category | Extensions |
|----------|------------|
| **Images** | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg` |
| **Text** | `.txt`, `.md`, `.log`, `.sh`, `.env` |
| **Code** | `.json`, `.js`, `.ts`, `.tsx`, `.jsx`, `.html`, `.css` |
| **Data** | `.csv`, `.xml`, `.yaml`, `.yml` |
| **Documents** | `.pdf` |
## Development ## Development
```bash ```bash
# Clone the repository
git clone https://code.foss.global/git.zone/tsview.git
cd tsview
# Install dependencies # Install dependencies
pnpm install pnpm install
# Build (bundles UI + compiles TypeScript) # Build (bundles frontend + compiles TypeScript)
pnpm build pnpm build
# Development mode with hot reload
pnpm run watch
# Run tests # Run tests
pnpm test pnpm test
# Run in development mode
./cli.ts.js
``` ```
## License ## Architecture
MIT License - see [license](./license) for details. ```
tsview/
├── ts/ # Backend TypeScript source
│ ├── api/ # TypedRequest API handlers
│ │ ├── handlers.s3.ts
│ │ └── handlers.mongodb.ts
│ ├── config/ # Configuration management
│ ├── server/ # Web server (TypedServer)
│ ├── interfaces/ # Shared TypeScript interfaces
│ └── tsview.classes.tsview.ts # Main class
├── ts_web/ # Frontend TypeScript source
│ ├── elements/ # Web components (LitElement)
│ ├── services/ # API client service
│ ├── styles/ # Shared theme styles
│ └── utilities/ # Helper functions
└── cli.ts.js # CLI entry point
```
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**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.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -37,7 +37,7 @@ tap.test('should have config methods', async () => {
tap.test('should have runCli export', async () => { tap.test('should have runCli export', async () => {
expect(tsview.runCli).toBeDefined(); expect(tsview.runCli).toBeDefined();
expect(typeof tsview.runCli).toBe('function'); expect(typeof tsview.runCli).toEqual('function');
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,8 +1,8 @@
/** /**
* autocreated commitance data by @push.rocks/commitinfo * autocreated commitinfo by @push.rocks/commitinfo
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', name: '@git.zone/tsview',
version: '1.0.0', version: '1.1.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI', description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
}; }

View File

@@ -87,6 +87,43 @@ export async function registerMongoHandlers(
) )
); );
// Create database (by creating a placeholder collection)
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateDatabase>(
'createDatabase',
async (reqData) => {
try {
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
// Create a placeholder collection to materialize the database
await db.createCollection('_tsview_init');
return { success: true };
} catch (err) {
console.error('Error creating database:', err);
return { success: false };
}
}
)
);
// Drop database
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_DropDatabase>(
'dropDatabase',
async (reqData) => {
try {
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
await db.dropDatabase();
return { success: true };
} catch (err) {
console.error('Error dropping database:', err);
return { success: false };
}
}
)
);
// Create collection // Create collection
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateCollection>( new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateCollection>(
@@ -105,6 +142,24 @@ export async function registerMongoHandlers(
) )
); );
// Drop collection
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_DropCollection>(
'dropCollection',
async (reqData) => {
try {
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
await db.dropCollection(reqData.collectionName);
return { success: true };
} catch (err) {
console.error('Error dropping collection:', err);
return { success: false };
}
}
)
);
// Find documents // Find documents
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_FindDocuments>( new plugins.typedrequest.TypedHandler<interfaces.IReq_FindDocuments>(

View File

@@ -9,20 +9,29 @@ export async function registerS3Handlers(
typedrouter: plugins.typedrequest.TypedRouter, typedrouter: plugins.typedrequest.TypedRouter,
tsview: TsView tsview: TsView
): Promise<void> { ): Promise<void> {
console.log('Registering S3 handlers...');
// List all buckets // List all buckets
console.log('Registering listBuckets handler');
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListBuckets>( new plugins.typedrequest.TypedHandler<interfaces.IReq_ListBuckets>(
'listBuckets', 'listBuckets',
async () => { async () => {
console.log('listBuckets handler called');
const smartbucket = await tsview.getSmartBucket(); const smartbucket = await tsview.getSmartBucket();
console.log('smartbucket:', smartbucket ? 'initialized' : 'null');
if (!smartbucket) { if (!smartbucket) {
console.log('returning empty buckets');
return { buckets: [] }; return { buckets: [] };
} }
try { try {
const command = new plugins.s3.ListBucketsCommand({}); const command = new plugins.s3.ListBucketsCommand({});
console.log('sending ListBucketsCommand...');
const response = await smartbucket.s3Client.send(command) as plugins.s3.ListBucketsCommandOutput; const response = await smartbucket.s3Client.send(command) as plugins.s3.ListBucketsCommandOutput;
console.log('response:', response);
const buckets = response.Buckets?.map(b => b.Name).filter((name): name is string => !!name) || []; const buckets = response.Buckets?.map(b => b.Name).filter((name): name is string => !!name) || [];
console.log('returning buckets:', buckets);
return { buckets }; return { buckets };
} catch (err) { } catch (err) {
console.error('Error listing buckets:', err); console.error('Error listing buckets:', err);
@@ -169,10 +178,21 @@ export async function registerS3Handlers(
'html': 'text/html', 'html': 'text/html',
'css': 'text/css', 'css': 'text/css',
'js': 'application/javascript', 'js': 'application/javascript',
'ts': 'text/plain',
'tsx': 'text/plain',
'jsx': 'text/plain',
'md': 'text/markdown',
'csv': 'text/csv',
'yaml': 'text/yaml',
'yml': 'text/yaml',
'log': 'text/plain',
'sh': 'text/plain',
'env': 'text/plain',
'png': 'image/png', 'png': 'image/png',
'jpg': 'image/jpeg', 'jpg': 'image/jpeg',
'jpeg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml', 'svg': 'image/svg+xml',
'pdf': 'application/pdf', 'pdf': 'application/pdf',
'xml': 'application/xml', 'xml': 'application/xml',
@@ -216,11 +236,26 @@ export async function registerS3Handlers(
'json': 'application/json', 'json': 'application/json',
'txt': 'text/plain', 'txt': 'text/plain',
'html': 'text/html', 'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'ts': 'text/plain',
'tsx': 'text/plain',
'jsx': 'text/plain',
'md': 'text/markdown',
'csv': 'text/csv',
'yaml': 'text/yaml',
'yml': 'text/yaml',
'log': 'text/plain',
'sh': 'text/plain',
'env': 'text/plain',
'png': 'image/png', 'png': 'image/png',
'jpg': 'image/jpeg', 'jpg': 'image/jpeg',
'jpeg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'pdf': 'application/pdf', 'pdf': 'application/pdf',
'xml': 'application/xml',
}; };
const contentType = contentTypeMap[ext] || 'application/octet-stream'; const contentType = contentTypeMap[ext] || 'application/octet-stream';

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,15 @@ export interface ITsViewConfig {
mongo?: IMongoConfig; mongo?: IMongoConfig;
} }
/**
* Configuration from npmextra.json for @git.zone/tsview
*/
export interface INpmextraConfig {
port?: number; // Fixed port to use (optional)
killIfBusy?: boolean; // Kill process on port if busy (default: false)
openBrowser?: boolean; // Open browser on start (default: true)
}
/** /**
* Environment configuration from .nogit/env.json (gitzone service format) * Environment configuration from .nogit/env.json (gitzone service format)
*/ */
@@ -229,6 +238,32 @@ export interface IReq_ListCollections extends plugins.typedrequestInterfaces.imp
}; };
} }
export interface IReq_CreateDatabase extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateDatabase
> {
method: 'createDatabase';
request: {
databaseName: string;
};
response: {
success: boolean;
};
}
export interface IReq_DropDatabase extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DropDatabase
> {
method: 'dropDatabase';
request: {
databaseName: string;
};
response: {
success: boolean;
};
}
export interface IReq_CreateCollection extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_CreateCollection extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateCollection IReq_CreateCollection
@@ -243,6 +278,20 @@ export interface IReq_CreateCollection extends plugins.typedrequestInterfaces.im
}; };
} }
export interface IReq_DropCollection extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DropCollection
> {
method: 'dropCollection';
request: {
databaseName: string;
collectionName: string;
};
response: {
success: boolean;
};
}
export interface IReq_FindDocuments extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_FindDocuments extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_FindDocuments IReq_FindDocuments

View File

@@ -23,15 +23,6 @@ export class ViewServer {
* Start the server * Start the server
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
// Register API handlers
if (this.tsview.config.hasS3()) {
await registerS3Handlers(this.typedrouter, this.tsview);
}
if (this.tsview.config.hasMongo()) {
await registerMongoHandlers(this.typedrouter, this.tsview);
}
// Create typed server with bundled content // Create typed server with bundled content
this.typedServer = new plugins.typedserver.TypedServer({ this.typedServer = new plugins.typedserver.TypedServer({
cors: true, cors: true,
@@ -41,8 +32,14 @@ export class ViewServer {
noCache: true, noCache: true,
}); });
// Add the router // Register API handlers directly to server's router
this.typedServer.typedrouter.addTypedRouter(this.typedrouter); if (this.tsview.config.hasS3()) {
await registerS3Handlers(this.typedServer.typedrouter, this.tsview);
}
if (this.tsview.config.hasMongo()) {
await registerMongoHandlers(this.typedServer.typedrouter, this.tsview);
}
// Start server // Start server
await this.typedServer.start(); await this.typedServer.start();

View File

@@ -3,6 +3,10 @@ import * as paths from './paths.js';
import type * as interfaces from './interfaces/index.js'; import type * as interfaces from './interfaces/index.js';
import { TsViewConfig } from './config/index.js'; import { TsViewConfig } from './config/index.js';
import { ViewServer } from './server/index.js'; import { ViewServer } from './server/index.js';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/** /**
* Main TsView class. * Main TsView class.
@@ -99,25 +103,88 @@ export class TsView {
} }
/** /**
* Start the viewer server * Load configuration from npmextra.json
* @param port - Optional port number (if not provided, finds a free port from 3010+)
*/ */
public async start(port?: number): Promise<number> { private loadNpmextraConfig(cwd?: string): interfaces.INpmextraConfig {
const actualPort = port ?? await this.findFreePort(3010); const npmextra = new plugins.npmextra.Npmextra(cwd || process.cwd());
const config = npmextra.dataFor<interfaces.INpmextraConfig>('@git.zone/tsview', {});
return config || {};
}
this.server = new ViewServer(this, actualPort); /**
* Kill process running on the specified port
*/
private async killProcessOnPort(port: number): Promise<void> {
try {
// Get PID using lsof (works on Linux and macOS)
const { stdout } = await execAsync(`lsof -ti :${port}`);
const pids = stdout.trim().split('\n').filter(Boolean);
for (const pid of pids) {
console.log(`Killing process ${pid} on port ${port}`);
await execAsync(`kill -9 ${pid}`);
}
// Brief wait for port to be released
await new Promise(resolve => setTimeout(resolve, 500));
} catch (e) {
// No process on port or lsof not available, ignore
}
}
/**
* Start the viewer server
* @param cliPort - Optional port number from CLI (highest priority)
*/
public async start(cliPort?: number): Promise<number> {
const npmextraConfig = await this.loadNpmextraConfig();
let port: number;
let portWasExplicitlySet = false;
if (cliPort) {
// CLI has highest priority
port = cliPort;
portWasExplicitlySet = true;
} else if (npmextraConfig.port) {
// Config port specified
port = npmextraConfig.port;
portWasExplicitlySet = true;
} else {
// Auto-find free port
port = await this.findFreePort(3010);
}
// Check if port is busy and handle accordingly
const network = new plugins.smartnetwork.SmartNetwork();
const isFree = await network.isLocalPortUnused(port);
if (!isFree) {
if (npmextraConfig.killIfBusy) {
console.log(`Port ${port} is busy. Killing existing process...`);
await this.killProcessOnPort(port);
} else if (portWasExplicitlySet) {
throw new Error(`Port ${port} is busy. Set "killIfBusy": true in npmextra.json to auto-kill, or use a different port.`);
} else {
// Auto port was already free, shouldn't happen, but fallback
port = await this.findFreePort(port + 1);
}
}
this.server = new ViewServer(this, port);
await this.server.start(); await this.server.start();
console.log(`TsView server started on http://localhost:${actualPort}`); console.log(`TsView server started on http://localhost:${port}`);
// Open browser // Open browser (default: true, can be disabled via config)
const shouldOpenBrowser = npmextraConfig.openBrowser !== false;
if (shouldOpenBrowser) {
try { try {
await plugins.smartopen.openUrl(`http://localhost:${actualPort}`); await plugins.smartopen.openUrl(`http://localhost:${port}`);
} catch (err) { } catch (err) {
// Ignore browser open errors // Ignore browser open errors
} }
}
return actualPort; return port;
} }
/** /**

View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@git.zone/tsview',
version: '1.1.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, state, DeesElement } = plugins; const { html, css, cssManager, customElement, state, DeesElement } = plugins;
@@ -37,8 +38,15 @@ export class TsviewApp extends DeesElement {
@state() @state()
private accessor newCollectionName: string = ''; private accessor newCollectionName: string = '';
@state()
private accessor showCreateDatabaseDialog: boolean = false;
@state()
private accessor newDatabaseName: string = '';
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -47,7 +55,7 @@ export class TsviewApp extends DeesElement {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: #1a1a2e; background: var(--tsview-bg-primary, #1a1a1a);
color: #eee; color: #eee;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} }
@@ -59,7 +67,7 @@ export class TsviewApp extends DeesElement {
} }
.app-header { .app-header {
background: #16162a; background: #141414;
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -103,8 +111,8 @@ export class TsviewApp extends DeesElement {
} }
.nav-tab.active { .nav-tab.active {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
color: #818cf8; color: #e0e0e0;
} }
.app-main { .app-main {
@@ -114,7 +122,7 @@ export class TsviewApp extends DeesElement {
} }
.sidebar { .sidebar {
background: #1e1e38; background: #1e1e1e;
border-right: 1px solid #333; border-right: 1px solid #333;
overflow-y: auto; overflow-y: auto;
} }
@@ -148,8 +156,8 @@ export class TsviewApp extends DeesElement {
} }
.sidebar-item.selected { .sidebar-item.selected {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
color: #818cf8; color: #e0e0e0;
} }
.sidebar-item .count { .sidebar-item .count {
@@ -219,8 +227,8 @@ export class TsviewApp extends DeesElement {
} }
.collection-item.selected { .collection-item.selected {
background: rgba(99, 102, 241, 0.15); background: rgba(255, 255, 255, 0.08);
color: #818cf8; color: #e0e0e0;
} }
.create-btn { .create-btn {
@@ -229,18 +237,18 @@ export class TsviewApp extends DeesElement {
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;
margin: 8px; margin: 8px;
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border: 1px dashed rgba(99, 102, 241, 0.4); border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 6px; border-radius: 6px;
color: #818cf8; color: #e0e0e0;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
transition: all 0.2s; transition: all 0.2s;
} }
.create-btn:hover { .create-btn:hover {
background: rgba(99, 102, 241, 0.3); background: rgba(255, 255, 255, 0.15);
border-color: rgba(99, 102, 241, 0.6); border-color: rgba(255, 255, 255, 0.3);
} }
.dialog-overlay { .dialog-overlay {
@@ -257,7 +265,7 @@ export class TsviewApp extends DeesElement {
} }
.dialog { .dialog {
background: #1e1e38; background: #1e1e1e;
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
min-width: 400px; min-width: 400px;
@@ -274,7 +282,7 @@ export class TsviewApp extends DeesElement {
.dialog-input { .dialog-input {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
background: #16162a; background: #141414;
border: 1px solid #333; border: 1px solid #333;
border-radius: 6px; border-radius: 6px;
color: #fff; color: #fff;
@@ -285,7 +293,7 @@ export class TsviewApp extends DeesElement {
.dialog-input:focus { .dialog-input:focus {
outline: none; outline: none;
border-color: #818cf8; border-color: #e0e0e0;
} }
.dialog-actions { .dialog-actions {
@@ -314,19 +322,67 @@ export class TsviewApp extends DeesElement {
} }
.dialog-btn-create { .dialog-btn-create {
background: #6366f1; background: #404040;
border: none; border: none;
color: #fff; color: #fff;
} }
.dialog-btn-create:hover { .dialog-btn-create:hover {
background: #5558e8; background: #505050;
} }
.dialog-btn-create:disabled { .dialog-btn-create:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.dialog-btn-delete {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #f87171;
}
.dialog-btn-delete:hover {
background: rgba(239, 68, 68, 0.3);
}
.sidebar-item-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.sidebar-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.sidebar-item:hover .delete-btn,
.db-group-header:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`, `,
]; ];
@@ -392,6 +448,53 @@ export class TsviewApp extends DeesElement {
} }
} }
private async createDatabase() {
if (!this.newDatabaseName.trim()) return;
const success = await apiService.createDatabase(this.newDatabaseName.trim());
if (success) {
this.databases = [...this.databases, { name: this.newDatabaseName.trim() }];
this.newDatabaseName = '';
this.showCreateDatabaseDialog = false;
}
}
private async deleteBucket(bucket: string, e: Event) {
e.stopPropagation();
if (!confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) return;
const success = await apiService.deleteBucket(bucket);
if (success) {
this.buckets = this.buckets.filter(b => b !== bucket);
if (this.selectedBucket === bucket) {
this.selectedBucket = this.buckets[0] || '';
}
}
}
private async deleteDatabase(dbName: string, e: Event) {
e.stopPropagation();
if (!confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) return;
const success = await apiService.dropDatabase(dbName);
if (success) {
this.databases = this.databases.filter(d => d.name !== dbName);
if (this.selectedDatabase === dbName) {
this.selectedDatabase = this.databases[0]?.name || '';
this.selectedCollection = '';
}
}
}
private async deleteCollection(dbName: string, collectionName: string) {
if (!confirm(`Delete collection "${collectionName}"? This will delete all documents.`)) return;
const success = await apiService.dropCollection(dbName, collectionName);
if (success) {
if (this.selectedCollection === collectionName) {
this.selectedCollection = '';
}
// Force refresh of the collections list
this.requestUpdate();
}
}
render() { render() {
return html` return html`
<div class="app-container"> <div class="app-container">
@@ -433,6 +536,7 @@ export class TsviewApp extends DeesElement {
</div> </div>
${this.renderCreateBucketDialog()} ${this.renderCreateBucketDialog()}
${this.renderCreateCollectionDialog()} ${this.renderCreateCollectionDialog()}
${this.renderCreateDatabaseDialog()}
`; `;
} }
@@ -498,6 +602,37 @@ export class TsviewApp extends DeesElement {
`; `;
} }
private renderCreateDatabaseDialog() {
if (!this.showCreateDatabaseDialog) return '';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDatabaseDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">Create New Database</div>
<input
type="text"
class="dialog-input"
placeholder="Database name"
.value=${this.newDatabaseName}
@input=${(e: InputEvent) => this.newDatabaseName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createDatabase()}
/>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDatabaseDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.newDatabaseName.trim()}
@click=${() => this.createDatabase()}
>
Create
</button>
</div>
</div>
</div>
`;
}
private renderSidebar() { private renderSidebar() {
if (this.viewMode === 's3') { if (this.viewMode === 's3') {
return html` return html`
@@ -519,7 +654,13 @@ export class TsviewApp extends DeesElement {
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}" class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
@click=${() => this.selectBucket(bucket)} @click=${() => this.selectBucket(bucket)}
> >
${bucket} <span class="sidebar-item-name">${bucket}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteBucket(bucket, e)} title="Delete bucket">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div> </div>
` `
)} )}
@@ -532,6 +673,13 @@ export class TsviewApp extends DeesElement {
return html` return html`
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header">Databases & Collections</div> <div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Database
</button>
${this.selectedDatabase ? html` ${this.selectedDatabase ? html`
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}> <button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -573,7 +721,13 @@ export class TsviewApp extends DeesElement {
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path> <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path> <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg> </svg>
${db.name} <span style="flex: 1;">${db.name}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteDatabase(db.name, e)} title="Delete database">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div> </div>
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''} ${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
</div> </div>
@@ -586,10 +740,18 @@ export class TsviewApp extends DeesElement {
.databaseName=${dbName} .databaseName=${dbName}
.selectedCollection=${this.selectedCollection} .selectedCollection=${this.selectedCollection}
@collection-selected=${(e: CustomEvent) => this.selectCollection(e.detail)} @collection-selected=${(e: CustomEvent) => this.selectCollection(e.detail)}
@collection-deleted=${(e: CustomEvent) => this.handleCollectionDeleted(e)}
></tsview-mongo-collections> ></tsview-mongo-collections>
`; `;
} }
private handleCollectionDeleted(e: CustomEvent) {
const { collectionName } = e.detail;
if (this.selectedCollection === collectionName) {
this.selectedCollection = '';
}
}
private renderContent() { private renderContent() {
if (this.viewMode === 's3') { if (this.viewMode === 's3') {
if (!this.selectedBucket) { if (!this.selectedBucket) {

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type ICollectionStats } from '../services/index.js'; import { apiService, type ICollectionStats } from '../services/index.js';
import { formatSize, formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -24,6 +26,7 @@ export class TsviewMongoBrowser extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -92,8 +95,8 @@ export class TsviewMongoBrowser extends DeesElement {
} }
.tab.active { .tab.active {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
color: #818cf8; color: #e0e0e0;
} }
.content { .content {
@@ -159,23 +162,6 @@ export class TsviewMongoBrowser extends DeesElement {
this.selectedDocumentId = e.detail.documentId; this.selectedDocumentId = e.detail.documentId;
} }
private formatCount(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
private formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
render() { render() {
return html` return html`
<div class="browser-container"> <div class="browser-container">
@@ -185,8 +171,8 @@ export class TsviewMongoBrowser extends DeesElement {
${this.stats ${this.stats
? html` ? html`
<div class="collection-stats"> <div class="collection-stats">
<span class="stat-item">${this.formatCount(this.stats.count)} docs</span> <span class="stat-item">${formatCount(this.stats.count)} docs</span>
<span class="stat-item">${this.formatSize(this.stats.size)}</span> <span class="stat-item">${formatSize(this.stats.size)}</span>
<span class="stat-item">${this.stats.indexCount} indexes</span> <span class="stat-item">${this.stats.indexCount} indexes</span>
</div> </div>
` `

View File

@@ -1,8 +1,16 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type IMongoCollection } from '../services/index.js'; import { apiService, type IMongoCollection } from '../services/index.js';
import { formatCount } from '../utilities/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;
declare global {
interface HTMLElementEventMap {
'collection-deleted': CustomEvent<{ databaseName: string; collectionName: string }>;
}
}
@customElement('tsview-mongo-collections') @customElement('tsview-mongo-collections')
export class TsviewMongoCollections extends DeesElement { export class TsviewMongoCollections extends DeesElement {
@property({ type: String }) @property({ type: String })
@@ -19,6 +27,7 @@ export class TsviewMongoCollections extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -44,8 +53,8 @@ export class TsviewMongoCollections extends DeesElement {
} }
.collection-item.selected { .collection-item.selected {
background: rgba(99, 102, 241, 0.15); background: rgba(255, 255, 255, 0.08);
color: #818cf8; color: #e0e0e0;
} }
.collection-name { .collection-name {
@@ -80,6 +89,29 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px; font-size: 12px;
font-style: italic; font-style: italic;
} }
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.collection-item:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`, `,
]; ];
@@ -117,11 +149,25 @@ export class TsviewMongoCollections extends DeesElement {
); );
} }
private formatCount(count?: number): string { private async deleteCollection(name: string, e: Event) {
if (count === undefined) return ''; e.stopPropagation();
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString(); const success = await apiService.dropCollection(this.databaseName, name);
if (success) {
this.collections = this.collections.filter(c => c.name !== name);
this.dispatchEvent(
new CustomEvent('collection-deleted', {
detail: { databaseName: this.databaseName, collectionName: name },
bubbles: true,
composed: true,
})
);
}
}
public async refresh() {
await this.loadCollections();
} }
render() { render() {
@@ -147,9 +193,17 @@ export class TsviewMongoCollections extends DeesElement {
</svg> </svg>
${coll.name} ${coll.name}
</span> </span>
<span style="display: flex; align-items: center; gap: 4px;">
${coll.count !== undefined ${coll.count !== undefined
? html`<span class="collection-count">${this.formatCount(coll.count)}</span>` ? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''} : ''}
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</span>
</div> </div>
` `
)} )}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService } from '../services/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;
@@ -31,6 +32,7 @@ export class TsviewMongoDocument extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -78,13 +80,13 @@ export class TsviewMongoDocument extends DeesElement {
} }
.action-btn.primary { .action-btn.primary {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border-color: #6366f1; border-color: #404040;
color: #818cf8; color: #e0e0e0;
} }
.action-btn.primary:hover { .action-btn.primary:hover {
background: rgba(99, 102, 241, 0.3); background: rgba(255, 255, 255, 0.15);
} }
.action-btn.danger { .action-btn.danger {
@@ -113,7 +115,7 @@ export class TsviewMongoDocument extends DeesElement {
} }
.json-key { .json-key {
color: #818cf8; color: #e0e0e0;
} }
.json-string { .json-string {
@@ -148,7 +150,7 @@ export class TsviewMongoDocument extends DeesElement {
.edit-area:focus { .edit-area:focus {
outline: none; outline: none;
border-color: #6366f1; border-color: #404040;
} }
.empty-state { .empty-state {

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService } from '../services/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;
@@ -34,6 +35,7 @@ export class TsviewMongoDocuments extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -67,7 +69,7 @@ export class TsviewMongoDocuments extends DeesElement {
.filter-input:focus { .filter-input:focus {
outline: none; outline: none;
border-color: #6366f1; border-color: #404040;
} }
.filter-input::placeholder { .filter-input::placeholder {
@@ -76,16 +78,16 @@ export class TsviewMongoDocuments extends DeesElement {
.filter-btn { .filter-btn {
padding: 8px 16px; padding: 8px 16px;
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border: 1px solid #6366f1; border: 1px solid #404040;
color: #818cf8; color: #e0e0e0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.filter-btn:hover { .filter-btn:hover {
background: rgba(99, 102, 241, 0.3); background: rgba(255, 255, 255, 0.15);
} }
.documents-list { .documents-list {
@@ -108,13 +110,13 @@ export class TsviewMongoDocuments extends DeesElement {
} }
.document-row.selected { .document-row.selected {
background: rgba(99, 102, 241, 0.15); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(99, 102, 241, 0.3); border: 1px solid rgba(255, 255, 255, 0.15);
} }
.document-id { .document-id {
font-size: 12px; font-size: 12px;
color: #818cf8; color: #e0e0e0;
font-family: monospace; font-family: monospace;
margin-bottom: 4px; margin-bottom: 4px;
} }

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type IMongoIndex } from '../services/index.js'; import { apiService, type IMongoIndex } from '../services/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;
@@ -31,6 +32,7 @@ export class TsviewMongoIndexes extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -106,8 +108,8 @@ export class TsviewMongoIndexes extends DeesElement {
} }
.badge.unique { .badge.unique {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
color: #818cf8; color: #e0e0e0;
} }
.badge.sparse { .badge.sparse {
@@ -181,7 +183,7 @@ export class TsviewMongoIndexes extends DeesElement {
} }
.dialog { .dialog {
background: #1e1e38; background: #1e1e1e;
border: 1px solid #333; border: 1px solid #333;
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
@@ -219,7 +221,7 @@ export class TsviewMongoIndexes extends DeesElement {
.dialog-input:focus { .dialog-input:focus {
outline: none; outline: none;
border-color: #6366f1; border-color: #404040;
} }
.dialog-checkbox { .dialog-checkbox {
@@ -255,13 +257,13 @@ export class TsviewMongoIndexes extends DeesElement {
} }
.dialog-btn.primary { .dialog-btn.primary {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border: 1px solid #6366f1; border: 1px solid #404040;
color: #818cf8; color: #e0e0e0;
} }
.dialog-btn.primary:hover { .dialog-btn.primary:hover {
background: rgba(99, 102, 241, 0.3); background: rgba(255, 255, 255, 0.15);
} }
`, `,
]; ];

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js'; import { apiService } from '../services/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;
@@ -19,8 +20,12 @@ export class TsviewS3Browser extends DeesElement {
@state() @state()
private accessor selectedKey: string = ''; private accessor selectedKey: string = '';
@state()
private accessor refreshKey: number = 0;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -90,19 +95,23 @@ export class TsviewS3Browser extends DeesElement {
} }
.view-btn.active { .view-btn.active {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border-color: #6366f1; border-color: #404040;
color: #818cf8; color: #e0e0e0;
} }
.content { .content {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 350px; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
overflow: hidden; overflow: hidden;
} }
.content.has-preview {
grid-template-columns: 1fr 350px;
}
.main-view { .main-view {
overflow: auto; overflow: auto;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
@@ -116,7 +125,8 @@ export class TsviewS3Browser extends DeesElement {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.content { .content,
.content.has-preview {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -144,6 +154,20 @@ export class TsviewS3Browser extends DeesElement {
this.navigateToPrefix(e.detail.prefix); 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 = '';
}
}
render() { render() {
const breadcrumbParts = this.currentPrefix const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean) ? this.currentPrefix.split('/').filter(Boolean)
@@ -189,13 +213,14 @@ export class TsviewS3Browser extends DeesElement {
</div> </div>
</div> </div>
<div class="content"> <div class="content ${this.selectedKey ? 'has-preview' : ''}">
<div class="main-view"> <div class="main-view">
${this.viewType === 'columns' ${this.viewType === 'columns'
? html` ? html`
<tsview-s3-columns <tsview-s3-columns
.bucketName=${this.bucketName} .bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix} .currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected} @key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate} @navigate=${this.handleNavigate}
></tsview-s3-columns> ></tsview-s3-columns>
@@ -204,18 +229,24 @@ export class TsviewS3Browser extends DeesElement {
<tsview-s3-keys <tsview-s3-keys
.bucketName=${this.bucketName} .bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix} .currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected} @key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate} @navigate=${this.handleNavigate}
></tsview-s3-keys> ></tsview-s3-keys>
`} `}
</div> </div>
${this.selectedKey
? html`
<div class="preview-panel"> <div class="preview-panel">
<tsview-s3-preview <tsview-s3-preview
.bucketName=${this.bucketName} .bucketName=${this.bucketName}
.objectKey=${this.selectedKey} .objectKey=${this.selectedKey}
@object-deleted=${this.handleObjectDeleted}
></tsview-s3-preview> ></tsview-s3-preview>
</div> </div>
`
: ''}
</div> </div>
</div> </div>
`; `;

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js'; import { apiService, type IS3Object } from '../services/index.js';
import { getFileName } from '../utilities/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;
@@ -19,6 +21,9 @@ export class TsviewS3Columns extends DeesElement {
@property({ type: String }) @property({ type: String })
public accessor currentPrefix: string = ''; public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state() @state()
private accessor columns: IColumn[] = []; private accessor columns: IColumn[] = [];
@@ -32,6 +37,7 @@ export class TsviewS3Columns extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -81,7 +87,7 @@ export class TsviewS3Columns extends DeesElement {
.resize-handle:hover::after, .resize-handle:hover::after,
.resize-handle.active::after { .resize-handle.active::after {
background: #6366f1; background: #404040;
width: 2px; width: 2px;
left: 1px; left: 1px;
} }
@@ -124,8 +130,8 @@ export class TsviewS3Columns extends DeesElement {
} }
.column-item.selected { .column-item.selected {
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
color: #818cf8; color: #e0e0e0;
} }
.column-item.folder { .column-item.folder {
@@ -172,9 +178,9 @@ export class TsviewS3Columns extends DeesElement {
} }
updated(changedProperties: Map<string, unknown>) { updated(changedProperties: Map<string, unknown>) {
// Only reset columns when bucket changes // Only reset columns when bucket changes or refresh is triggered
// Internal folder navigation is handled by selectFolder() which appends columns // Internal folder navigation is handled by selectFolder() which appends columns
if (changedProperties.has('bucketName')) { if (changedProperties.has('bucketName') || changedProperties.has('refreshKey')) {
this.loadInitialColumn(); this.loadInitialColumn();
} }
} }
@@ -298,11 +304,6 @@ export class TsviewS3Columns extends DeesElement {
); );
} }
private getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
private getFileIcon(key: string): string { private getFileIcon(key: string): string {
const ext = key.split('.').pop()?.toLowerCase() || ''; const ext = key.split('.').pop()?.toLowerCase() || '';
const iconMap: Record<string, string> = { const iconMap: Record<string, string> = {
@@ -343,7 +344,7 @@ export class TsviewS3Columns extends DeesElement {
private renderColumn(column: IColumn, index: number) { private renderColumn(column: IColumn, index: number) {
const headerName = column.prefix const headerName = column.prefix
? this.getFileName(column.prefix) ? getFileName(column.prefix)
: this.bucketName; : this.bucketName;
return html` return html`
@@ -364,7 +365,7 @@ export class TsviewS3Columns extends DeesElement {
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"> <svg class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> <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> </svg>
<span class="name">${this.getFileName(prefix)}</span> <span class="name">${getFileName(prefix)}</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline> <polyline points="9 18 15 12 9 6"></polyline>
</svg> </svg>
@@ -380,7 +381,7 @@ export class TsviewS3Columns extends DeesElement {
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${this.getFileIcon(obj.key)}" /> <path d="${this.getFileIcon(obj.key)}" />
</svg> </svg>
<span class="name">${this.getFileName(obj.key)}</span> <span class="name">${getFileName(obj.key)}</span>
</div> </div>
` `
)} )}

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.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 { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -11,6 +13,9 @@ export class TsviewS3Keys extends DeesElement {
@property({ type: String }) @property({ type: String })
public accessor currentPrefix: string = ''; public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state() @state()
private accessor allKeys: IS3Object[] = []; private accessor allKeys: IS3Object[] = [];
@@ -28,6 +33,7 @@ export class TsviewS3Keys extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -58,7 +64,7 @@ export class TsviewS3Keys extends DeesElement {
.filter-input:focus { .filter-input:focus {
outline: none; outline: none;
border-color: #6366f1; border-color: #404040;
} }
.filter-input::placeholder { .filter-input::placeholder {
@@ -78,7 +84,7 @@ export class TsviewS3Keys extends DeesElement {
thead { thead {
position: sticky; position: sticky;
top: 0; top: 0;
background: #1a1a2e; background: #1a1a1a;
z-index: 1; z-index: 1;
} }
@@ -103,7 +109,7 @@ export class TsviewS3Keys extends DeesElement {
} }
tr.selected td { tr.selected td {
background: rgba(99, 102, 241, 0.15); background: rgba(255, 255, 255, 0.08);
} }
.key-cell { .key-cell {
@@ -148,7 +154,7 @@ export class TsviewS3Keys extends DeesElement {
} }
updated(changedProperties: Map<string, unknown>) { updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) { if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) {
this.loadObjects(); this.loadObjects();
} }
} }
@@ -193,30 +199,13 @@ export class TsviewS3Keys extends DeesElement {
} }
} }
private getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
private formatSize(bytes?: number): string {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
private get filteredItems() { private get filteredItems() {
const filter = this.filterText.toLowerCase(); const filter = this.filterText.toLowerCase();
const folders = this.prefixes const folders = this.prefixes
.filter((p) => !filter || this.getFileName(p).toLowerCase().includes(filter)) .filter((p) => !filter || getFileName(p).toLowerCase().includes(filter))
.map((p) => ({ key: p, isFolder: true, size: undefined })); .map((p) => ({ key: p, isFolder: true, size: undefined }));
const files = this.allKeys const files = this.allKeys
.filter((o) => !filter || this.getFileName(o.key).toLowerCase().includes(filter)) .filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter))
.map((o) => ({ key: o.key, isFolder: false, size: o.size })); .map((o) => ({ key: o.key, isFolder: false, size: o.size }));
return [...folders, ...files]; return [...folders, ...files];
} }
@@ -267,11 +256,11 @@ export class TsviewS3Keys extends DeesElement {
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</svg> </svg>
`} `}
<span class="key-name">${this.getFileName(item.key)}</span> <span class="key-name">${getFileName(item.key)}</span>
</div> </div>
</td> </td>
<td class="size-cell"> <td class="size-cell">
${item.isFolder ? '-' : this.formatSize(item.size)} ${item.isFolder ? '-' : formatSize(item.size)}
</td> </td>
</tr> </tr>
` `

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { apiService } from '../services/index.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; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -31,6 +33,7 @@ export class TsviewS3Preview extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -104,9 +107,9 @@ export class TsviewS3Preview extends DeesElement {
.action-btn { .action-btn {
flex: 1; flex: 1;
padding: 8px 16px; padding: 8px 16px;
background: rgba(99, 102, 241, 0.2); background: rgba(255, 255, 255, 0.1);
border: 1px solid #6366f1; border: 1px solid #404040;
color: #818cf8; color: #e0e0e0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
@@ -114,7 +117,7 @@ export class TsviewS3Preview extends DeesElement {
} }
.action-btn:hover { .action-btn:hover {
background: rgba(99, 102, 241, 0.3); background: rgba(255, 255, 255, 0.15);
} }
.action-btn.danger { .action-btn.danger {
@@ -174,6 +177,7 @@ export class TsviewS3Preview extends DeesElement {
} else { } else {
this.content = ''; this.content = '';
this.contentType = ''; this.contentType = '';
this.error = ''; // Clear error when no file selected
} }
} }
} }
@@ -198,22 +202,6 @@ export class TsviewS3Preview extends DeesElement {
this.loading = false; this.loading = false;
} }
private getFileName(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
private formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
private formatDate(dateStr: string): string { private formatDate(dateStr: string): string {
if (!dateStr) return '-'; if (!dateStr) return '-';
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -235,7 +223,13 @@ export class TsviewS3Preview extends DeesElement {
private getTextContent(): string { private getTextContent(): string {
try { try {
return atob(this.content); // 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 { } catch {
return 'Unable to decode content'; return 'Unable to decode content';
} }
@@ -249,7 +243,7 @@ export class TsviewS3Preview extends DeesElement {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = this.getFileName(this.objectKey); a.download = getFileName(this.objectKey);
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@@ -260,7 +254,7 @@ export class TsviewS3Preview extends DeesElement {
} }
private async handleDelete() { private async handleDelete() {
if (!confirm(`Delete "${this.getFileName(this.objectKey)}"?`)) return; if (!confirm(`Delete "${getFileName(this.objectKey)}"?`)) return;
try { try {
await apiService.deleteObject(this.bucketName, this.objectKey); await apiService.deleteObject(this.bucketName, this.objectKey);
@@ -310,10 +304,10 @@ export class TsviewS3Preview extends DeesElement {
return html` return html`
<div class="preview-container"> <div class="preview-container">
<div class="preview-header"> <div class="preview-header">
<div class="preview-title">${this.getFileName(this.objectKey)}</div> <div class="preview-title">${getFileName(this.objectKey)}</div>
<div class="preview-meta"> <div class="preview-meta">
<span class="meta-item">${this.contentType}</span> <span class="meta-item">${this.contentType}</span>
<span class="meta-item">${this.formatSize(this.size)}</span> <span class="meta-item">${formatSize(this.size)}</span>
<span class="meta-item">${this.formatDate(this.lastModified)}</span> <span class="meta-item">${this.formatDate(this.lastModified)}</span>
</div> </div>
</div> </div>

View File

@@ -153,6 +153,22 @@ export class ApiService {
return result.databases; return result.databases;
} }
async createDatabase(databaseName: string): Promise<boolean> {
const result = await this.request<{ databaseName: string }, { success: boolean }>(
'createDatabase',
{ databaseName }
);
return result.success;
}
async dropDatabase(databaseName: string): Promise<boolean> {
const result = await this.request<{ databaseName: string }, { success: boolean }>(
'dropDatabase',
{ databaseName }
);
return result.success;
}
async listCollections(databaseName: string): Promise<IMongoCollection[]> { async listCollections(databaseName: string): Promise<IMongoCollection[]> {
const result = await this.request< const result = await this.request<
{ databaseName: string }, { databaseName: string },
@@ -169,6 +185,14 @@ export class ApiService {
return result.success; return result.success;
} }
async dropCollection(databaseName: string, collectionName: string): Promise<boolean> {
const result = await this.request<
{ databaseName: string; collectionName: string },
{ success: boolean }
>('dropCollection', { databaseName, collectionName });
return result.success;
}
async findDocuments( async findDocuments(
databaseName: string, databaseName: string,
collectionName: string, collectionName: string,

1
ts_web/styles/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './theme.js';

61
ts_web/styles/theme.ts Normal file
View File

@@ -0,0 +1,61 @@
import { css } from '@design.estate/dees-element';
/**
* CSS custom properties (design tokens) for tsview components
* Add themeStyles to your component's static styles array to use these variables
*/
export const themeStyles = css`
:host {
/* Background colors */
--tsview-bg-primary: #1a1a1a;
--tsview-bg-secondary: #1e1e1e;
--tsview-bg-tertiary: #141414;
--tsview-bg-overlay: rgba(0, 0, 0, 0.2);
--tsview-bg-overlay-dark: rgba(0, 0, 0, 0.3);
--tsview-bg-dialog-overlay: rgba(0, 0, 0, 0.7);
/* Border colors */
--tsview-border-primary: #333;
--tsview-border-secondary: #444;
--tsview-border-tertiary: #2a2a3e;
/* Text colors */
--tsview-text-primary: #fff;
--tsview-text-secondary: #e0e0e0;
--tsview-text-tertiary: #ccc;
--tsview-text-muted: #888;
--tsview-text-dim: #666;
/* Interactive states */
--tsview-hover-bg: rgba(255, 255, 255, 0.05);
--tsview-hover-bg-strong: rgba(255, 255, 255, 0.1);
--tsview-selected-bg: rgba(255, 255, 255, 0.08);
--tsview-active-bg: rgba(255, 255, 255, 0.1);
/* Accent colors */
--tsview-accent-folder: #fbbf24;
/* Danger/destructive actions */
--tsview-danger: #ef4444;
--tsview-danger-text: #f87171;
--tsview-danger-bg: rgba(239, 68, 68, 0.2);
--tsview-danger-bg-hover: rgba(239, 68, 68, 0.3);
/* Success states */
--tsview-success: #22c55e;
--tsview-success-bg: rgba(34, 197, 94, 0.2);
/* Border radius */
--tsview-radius-sm: 4px;
--tsview-radius-md: 6px;
--tsview-radius-lg: 8px;
--tsview-radius-xl: 12px;
/* Spacing */
--tsview-spacing-xs: 4px;
--tsview-spacing-sm: 8px;
--tsview-spacing-md: 12px;
--tsview-spacing-lg: 16px;
--tsview-spacing-xl: 24px;
}
`;

View File

@@ -0,0 +1,46 @@
/**
* Shared formatting utilities for tsview web components
*/
/**
* Format a byte size into a human-readable string
* @param bytes - Size in bytes (can be undefined for S3 folders)
* @returns Formatted size string (e.g., "1.5 MB")
*/
export function formatSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return '-';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
/**
* Format a count into a compact human-readable string
* @param count - The count number (can be undefined)
* @returns Formatted count string (e.g., "1.5K", "2.3M")
*/
export function formatCount(count?: number): string {
if (count === undefined || count === null) return '';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}
/**
* Extract the file name from a path
* @param path - Full file path (e.g., "folder/subfolder/file.txt")
* @returns File name (e.g., "file.txt")
*/
export function getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}

View File

@@ -0,0 +1 @@
export * from './formatters.js';