initial
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.nogit/
|
||||||
|
|
||||||
|
# artifacts
|
||||||
|
coverage/
|
||||||
|
public/
|
||||||
|
|
||||||
|
# installs
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.yarn/
|
||||||
|
.cache/
|
||||||
|
.rpt2_cache
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
#------# custom
|
||||||
4
cli.child.ts
Normal file
4
cli.child.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.CLI_CALL = 'true';
|
||||||
|
import * as cliTool from './ts/index.js';
|
||||||
|
cliTool.runCli();
|
||||||
4
cli.js
Normal file
4
cli.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.CLI_CALL = 'true';
|
||||||
|
const cliTool = await import('./dist_ts/index.js');
|
||||||
|
cliTool.runCli();
|
||||||
5
cli.ts.js
Normal file
5
cli.ts.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.CLI_CALL = 'true';
|
||||||
|
|
||||||
|
import * as tsrun from '@git.zone/tsrun';
|
||||||
|
tsrun.runPath('./cli.child.js', import.meta.url);
|
||||||
19
html/index.html
Normal file
19
html/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TsView</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<tsview-app></tsview-app>
|
||||||
|
<script type="module" src="/bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Lossless GmbH (https://lossless.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
23
npmextra.json
Normal file
23
npmextra.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./ts/bundled_ui.ts",
|
||||||
|
"outputMode": "base64ts",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"includeFiles": ["html/**/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/tsview": {
|
||||||
|
"port": 3010,
|
||||||
|
"openBrowser": true
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"services": [
|
||||||
|
"mongodb",
|
||||||
|
"minio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "@git.zone/tsview",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
|
||||||
|
"main": "dist_ts/index.js",
|
||||||
|
"typings": "dist_ts/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"author": "Lossless GmbH",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"test": "pnpm run build && tstest test/ --verbose",
|
||||||
|
"build": "pnpm run bundle && tsbuild --allowimplicitany",
|
||||||
|
"bundle": "tsbundle"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsview": "cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
|
"@git.zone/tsbundle": "^2.8.3",
|
||||||
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
|
"@git.zone/tstest": "^3.1.6",
|
||||||
|
"@types/node": "^25.0.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
|
"@api.global/typedserver": "^8.2.0",
|
||||||
|
"@design.estate/dees-catalog": "^3.37.0",
|
||||||
|
"@design.estate/dees-element": "^2.1.5",
|
||||||
|
"@push.rocks/early": "^4.0.4",
|
||||||
|
"@push.rocks/npmextra": "^5.3.3",
|
||||||
|
"@push.rocks/smartbucket": "^4.3.0",
|
||||||
|
"@push.rocks/smartcli": "^4.0.20",
|
||||||
|
"@push.rocks/smartdata": "^7.0.15",
|
||||||
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
|
"@push.rocks/smartlog-destination-local": "^9.0.2",
|
||||||
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
|
"@push.rocks/smartopen": "^2.0.0",
|
||||||
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
|
"@push.rocks/smartpromise": "^4.2.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"ts_web/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_ts_web/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
],
|
||||||
|
"browserslist": [
|
||||||
|
"last 1 chrome versions"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://code.foss.global/git.zone/tsview.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
9530
pnpm-lock.yaml
generated
Normal file
9530
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
readme.hints.md
Normal file
49
readme.hints.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# tsview - Project Hints
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
tsview is a CLI tool for viewing S3 and MongoDB data through a web UI.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Reads from `.nogit/env.json` (created by `gitzone service`)
|
||||||
|
- Environment variables: MONGODB_URL, S3_HOST, S3_ACCESSKEY, etc.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
- `tsview` - Start viewer (auto-finds free port from 3010+)
|
||||||
|
- `tsview --port 3000` - Force specific port
|
||||||
|
- `tsview s3` - S3 viewer only
|
||||||
|
- `tsview mongo` - MongoDB viewer only
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Uses `@push.rocks/smartbucket` for S3 operations
|
||||||
|
- Uses `@push.rocks/smartdata` for MongoDB operations
|
||||||
|
- Uses `@api.global/typedserver` + `@api.global/typedrequest` for API
|
||||||
|
- Uses `@design.estate/dees-catalog` for UI components
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
- Run `pnpm build` to compile TypeScript and bundle web UI
|
||||||
|
- UI is bundled from `ts_web/` to `ts/bundled_ui.ts` as base64
|
||||||
|
|
||||||
|
### TypedRequest Pattern
|
||||||
|
```typescript
|
||||||
|
// Interface definition
|
||||||
|
export interface IReq_ListBuckets extends plugins.typedrequest.implementsTR<
|
||||||
|
plugins.typedrequest.ITypedRequest,
|
||||||
|
IReq_ListBuckets
|
||||||
|
> {
|
||||||
|
method: 'listBuckets';
|
||||||
|
request: {};
|
||||||
|
response: { buckets: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler registration
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<IReq_ListBuckets>(
|
||||||
|
'listBuckets',
|
||||||
|
async (reqData) => {
|
||||||
|
return { buckets: [...] };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
123
readme.md
Normal file
123
readme.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# @git.zone/tsview
|
||||||
|
|
||||||
|
A CLI tool for viewing S3 and MongoDB data with a web UI.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @git.zone/tsview
|
||||||
|
# or
|
||||||
|
pnpm add -g @git.zone/tsview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start viewer (auto-finds free port from 3010+)
|
||||||
|
tsview
|
||||||
|
|
||||||
|
# Force specific port
|
||||||
|
tsview --port 3000
|
||||||
|
|
||||||
|
# S3 viewer only
|
||||||
|
tsview s3
|
||||||
|
|
||||||
|
# MongoDB viewer only
|
||||||
|
tsview mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
tsview reads configuration from `.nogit/env.json` (the same format used by `gitzone service`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"S3_ENDPOINT": "localhost",
|
||||||
|
"S3_PORT": "9000",
|
||||||
|
"S3_ACCESSKEY": "minioadmin",
|
||||||
|
"S3_SECRETKEY": "minioadmin",
|
||||||
|
"S3_USESSL": false,
|
||||||
|
"MONGODB_URL": "mongodb://localhost:27017",
|
||||||
|
"MONGODB_NAME": "mydb"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TsView } from '@git.zone/tsview';
|
||||||
|
|
||||||
|
const viewer = new TsView();
|
||||||
|
|
||||||
|
// Option 1: Load from env.json (gitzone service)
|
||||||
|
await viewer.loadConfigFromEnv();
|
||||||
|
|
||||||
|
// Option 2: Custom local config (MinIO + local MongoDB)
|
||||||
|
viewer.setS3Config({
|
||||||
|
endpoint: 'localhost',
|
||||||
|
port: 9000,
|
||||||
|
accessKey: 'minioadmin',
|
||||||
|
accessSecret: 'minioadmin',
|
||||||
|
useSsl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Option 3: Cloud config (AWS S3 + MongoDB Atlas)
|
||||||
|
viewer.setS3Config({
|
||||||
|
endpoint: 's3.amazonaws.com',
|
||||||
|
accessKey: 'AKIAXXXXXXX',
|
||||||
|
accessSecret: 'secret',
|
||||||
|
useSsl: true,
|
||||||
|
region: 'us-east-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
viewer.setMongoConfig({
|
||||||
|
mongoDbUrl: 'mongodb+srv://user:pass@cluster.mongodb.net',
|
||||||
|
mongoDbName: 'mydb'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start on auto-found port
|
||||||
|
const port = await viewer.start();
|
||||||
|
console.log(`Viewer running on http://localhost:${port}`);
|
||||||
|
|
||||||
|
// Or force specific port
|
||||||
|
await viewer.start(3500);
|
||||||
|
|
||||||
|
// Stop when done
|
||||||
|
await viewer.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### S3 Browser
|
||||||
|
- **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
|
||||||
|
- **Database/Collection Navigation**: Hierarchical sidebar
|
||||||
|
- **Documents Table**: Paginated view with filtering
|
||||||
|
- **Document Editor**: Edit documents with JSON syntax highlighting
|
||||||
|
- **Index Management**: View, create, and drop indexes
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Build (bundles UI + compiles TypeScript)
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
./cli.ts.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [license](./license) for details.
|
||||||
83
readme.plan.md
Normal file
83
readme.plan.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# tsview Implementation Plan
|
||||||
|
|
||||||
|
A CLI tool for viewing S3 and MongoDB data with a web UI.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tsview/
|
||||||
|
├── cli.js # Production CLI (imports dist_ts/index.js)
|
||||||
|
├── cli.ts.js # Dev CLI (uses tsrun to run cli.child.ts)
|
||||||
|
├── cli.child.ts # Dev CLI entry (imports ts/index.js)
|
||||||
|
├── package.json # bin: { "tsview": "cli.js" }
|
||||||
|
├── npmextra.json # tsbundle config for UI bundling
|
||||||
|
├── tsconfig.json
|
||||||
|
├── readme.md
|
||||||
|
├── readme.hints.md
|
||||||
|
│
|
||||||
|
├── ts/ # Backend (Node.js)
|
||||||
|
│ ├── 00_commitinfo_data.ts
|
||||||
|
│ ├── index.ts # Main exports + runCli()
|
||||||
|
│ ├── plugins.ts # Centralized imports
|
||||||
|
│ ├── paths.ts
|
||||||
|
│ │
|
||||||
|
│ ├── tsview.cli.ts # CLI using smartcli
|
||||||
|
│ ├── tsview.classes.tsview.ts # Main TsView class
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # Configuration handling
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ └── classes.config.ts # Reads .nogit/env.json
|
||||||
|
│ │
|
||||||
|
│ ├── server/ # Web server
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ └── classes.viewserver.ts # Serves bundled UI + API
|
||||||
|
│ │
|
||||||
|
│ ├── api/ # API handlers
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── handlers.s3.ts # S3 operations
|
||||||
|
│ │ └── handlers.mongodb.ts # MongoDB operations
|
||||||
|
│ │
|
||||||
|
│ └── interfaces/
|
||||||
|
│ └── index.ts # ITsViewConfig, etc.
|
||||||
|
│
|
||||||
|
├── ts_web/ # Frontend (bundled)
|
||||||
|
│ ├── index.ts # Entry point
|
||||||
|
│ ├── plugins.ts
|
||||||
|
│ │
|
||||||
|
│ ├── elements/
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── tsview-app.ts # Main shell (dees-appui)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── tsview-s3-browser.ts # S3 browser container
|
||||||
|
│ │ ├── tsview-s3-columns.ts # Mac-style column view
|
||||||
|
│ │ ├── tsview-s3-keys.ts # Flat keys list
|
||||||
|
│ │ ├── tsview-s3-preview.ts # File preview
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── tsview-mongo-browser.ts # MongoDB browser container
|
||||||
|
│ │ ├── tsview-mongo-collections.ts
|
||||||
|
│ │ ├── tsview-mongo-documents.ts # Documents table
|
||||||
|
│ │ ├── tsview-mongo-document.ts # Document editor
|
||||||
|
│ │ ├── tsview-mongo-aggregation.ts # Pipeline builder
|
||||||
|
│ │ └── tsview-mongo-indexes.ts # Index management
|
||||||
|
│ │
|
||||||
|
│ └── services/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ └── api.service.ts # TypedRequest client
|
||||||
|
│
|
||||||
|
└── test/
|
||||||
|
└── test.tsview.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] Phase 1: Project Setup
|
||||||
|
- [x] Phase 2: Configuration & Core
|
||||||
|
- [x] Phase 3: Backend API
|
||||||
|
- [x] Phase 4: Frontend Shell
|
||||||
|
- [x] Phase 5: S3 UI
|
||||||
|
- [x] Phase 6: MongoDB UI
|
||||||
|
- [x] Phase 7: Integration & Polish
|
||||||
|
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
All phases have been implemented. The project can be built with `pnpm build`.
|
||||||
43
test/test.tsview.ts
Normal file
43
test/test.tsview.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as tsview from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should export TsView class', async () => {
|
||||||
|
expect(tsview.TsView).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create TsView instance', async () => {
|
||||||
|
const viewer = new tsview.TsView();
|
||||||
|
expect(viewer).toBeInstanceOf(tsview.TsView);
|
||||||
|
expect(viewer.config).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should have config methods', async () => {
|
||||||
|
const viewer = new tsview.TsView();
|
||||||
|
|
||||||
|
// Set S3 config
|
||||||
|
viewer.setS3Config({
|
||||||
|
endpoint: 'localhost',
|
||||||
|
port: 9000,
|
||||||
|
accessKey: 'test',
|
||||||
|
accessSecret: 'test',
|
||||||
|
useSsl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(viewer.config.hasS3()).toBeTrue();
|
||||||
|
expect(viewer.config.hasMongo()).toBeFalse();
|
||||||
|
|
||||||
|
// Set MongoDB config
|
||||||
|
viewer.setMongoConfig({
|
||||||
|
mongoDbUrl: 'mongodb://localhost:27017',
|
||||||
|
mongoDbName: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(viewer.config.hasMongo()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should have runCli export', async () => {
|
||||||
|
expect(tsview.runCli).toBeDefined();
|
||||||
|
expect(typeof tsview.runCli).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitance data by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@git.zone/tsview',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI',
|
||||||
|
};
|
||||||
421
ts/api/handlers.mongodb.ts
Normal file
421
ts/api/handlers.mongodb.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as interfaces from '../interfaces/index.js';
|
||||||
|
import type { TsView } from '../tsview.classes.tsview.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register MongoDB API handlers
|
||||||
|
*/
|
||||||
|
export async function registerMongoHandlers(
|
||||||
|
typedrouter: plugins.typedrequest.TypedRouter,
|
||||||
|
tsview: TsView
|
||||||
|
): Promise<void> {
|
||||||
|
// Helper to get the native MongoDB client
|
||||||
|
const getMongoClient = async () => {
|
||||||
|
const db = await tsview.getMongoDb();
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('MongoDB not configured');
|
||||||
|
}
|
||||||
|
// Access the underlying MongoDB client through smartdata
|
||||||
|
return (db as any).mongoDbClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create ObjectId filter
|
||||||
|
const createIdFilter = (documentId: string) => {
|
||||||
|
// Try to treat as ObjectId string - MongoDB driver will handle conversion
|
||||||
|
try {
|
||||||
|
// Import ObjectId from the mongodb package that smartdata uses
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
if (ObjectId.isValid(documentId)) {
|
||||||
|
return { _id: new ObjectId(documentId) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to string filter
|
||||||
|
}
|
||||||
|
return { _id: documentId };
|
||||||
|
};
|
||||||
|
|
||||||
|
// List databases
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListDatabases>(
|
||||||
|
'listDatabases',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const adminDb = client.db().admin();
|
||||||
|
const result = await adminDb.listDatabases();
|
||||||
|
|
||||||
|
const databases: interfaces.IMongoDatabase[] = result.databases.map((db: any) => ({
|
||||||
|
name: db.name,
|
||||||
|
sizeOnDisk: db.sizeOnDisk,
|
||||||
|
empty: db.empty,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { databases };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing databases:', err);
|
||||||
|
return { databases: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// List collections
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>(
|
||||||
|
'listCollections',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collectionsInfo = await db.listCollections().toArray();
|
||||||
|
|
||||||
|
const collections: interfaces.IMongoCollection[] = [];
|
||||||
|
for (const coll of collectionsInfo) {
|
||||||
|
const stats = await db.collection(coll.name).estimatedDocumentCount();
|
||||||
|
collections.push({
|
||||||
|
name: coll.name,
|
||||||
|
count: stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { collections };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing collections:', err);
|
||||||
|
return { collections: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateCollection>(
|
||||||
|
'createCollection',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
await db.createCollection(reqData.collectionName);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating collection:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find documents
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_FindDocuments>(
|
||||||
|
'findDocuments',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const filter = reqData.filter || {};
|
||||||
|
const projection = reqData.projection || {};
|
||||||
|
const sort = reqData.sort || {};
|
||||||
|
const skip = reqData.skip || 0;
|
||||||
|
const limit = reqData.limit || 50;
|
||||||
|
|
||||||
|
const [documents, total] = await Promise.all([
|
||||||
|
collection
|
||||||
|
.find(filter)
|
||||||
|
.project(projection)
|
||||||
|
.sort(sort)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray(),
|
||||||
|
collection.countDocuments(filter),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert ObjectId to string for JSON serialization
|
||||||
|
const serializedDocs = documents.map((doc: any) => {
|
||||||
|
if (doc._id) {
|
||||||
|
doc._id = doc._id.toString();
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { documents: serializedDocs, total };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error finding documents:', err);
|
||||||
|
return { documents: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single document
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetDocument>(
|
||||||
|
'getDocument',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const filter = createIdFilter(reqData.documentId);
|
||||||
|
const document = await collection.findOne(filter);
|
||||||
|
|
||||||
|
if (document && document._id) {
|
||||||
|
document._id = document._id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { document };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting document:', err);
|
||||||
|
return { document: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert document
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_InsertDocument>(
|
||||||
|
'insertDocument',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const result = await collection.insertOne(reqData.document);
|
||||||
|
|
||||||
|
return { insertedId: result.insertedId.toString() };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error inserting document:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update document
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_UpdateDocument>(
|
||||||
|
'updateDocument',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const filter = createIdFilter(reqData.documentId);
|
||||||
|
|
||||||
|
// Check if update has $ operators
|
||||||
|
const hasOperators = Object.keys(reqData.update).some(k => k.startsWith('$'));
|
||||||
|
const updateDoc = hasOperators ? reqData.update : { $set: reqData.update };
|
||||||
|
|
||||||
|
const result = await collection.updateOne(filter, updateDoc);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.modifiedCount > 0 || result.matchedCount > 0,
|
||||||
|
modifiedCount: result.modifiedCount,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating document:', err);
|
||||||
|
return { success: false, modifiedCount: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete document
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeleteDocument>(
|
||||||
|
'deleteDocument',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const filter = createIdFilter(reqData.documentId);
|
||||||
|
const result = await collection.deleteOne(filter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.deletedCount > 0,
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting document:', err);
|
||||||
|
return { success: false, deletedCount: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run aggregation pipeline
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_RunAggregation>(
|
||||||
|
'runAggregation',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const results = await collection.aggregate(reqData.pipeline).toArray();
|
||||||
|
|
||||||
|
// Convert ObjectIds to strings
|
||||||
|
const serializedResults = results.map((doc: any) => {
|
||||||
|
if (doc._id) {
|
||||||
|
doc._id = doc._id.toString();
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { results: serializedResults };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error running aggregation:', err);
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// List indexes
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListIndexes>(
|
||||||
|
'listIndexes',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const indexesInfo = await collection.indexes();
|
||||||
|
|
||||||
|
const indexes: interfaces.IMongoIndex[] = indexesInfo.map((idx: any) => ({
|
||||||
|
name: idx.name,
|
||||||
|
keys: idx.key,
|
||||||
|
unique: idx.unique || false,
|
||||||
|
sparse: idx.sparse || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { indexes };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing indexes:', err);
|
||||||
|
return { indexes: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create index
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateIndex>(
|
||||||
|
'createIndex',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const indexName = await collection.createIndex(reqData.keys, reqData.options || {});
|
||||||
|
|
||||||
|
return { indexName };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating index:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop index
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_DropIndex>(
|
||||||
|
'dropIndex',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
await collection.dropIndex(reqData.indexName);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error dropping index:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get collection stats
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetCollectionStats>(
|
||||||
|
'getCollectionStats',
|
||||||
|
async (reqData) => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const db = client.db(reqData.databaseName);
|
||||||
|
const collection = db.collection(reqData.collectionName);
|
||||||
|
|
||||||
|
const stats = await db.command({ collStats: reqData.collectionName });
|
||||||
|
const indexCount = (await collection.indexes()).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
count: stats.count || 0,
|
||||||
|
size: stats.size || 0,
|
||||||
|
avgObjSize: stats.avgObjSize || 0,
|
||||||
|
storageSize: stats.storageSize || 0,
|
||||||
|
indexCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting collection stats:', err);
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
size: 0,
|
||||||
|
avgObjSize: 0,
|
||||||
|
storageSize: 0,
|
||||||
|
indexCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get server status
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetServerStatus>(
|
||||||
|
'getServerStatus',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const client = await getMongoClient();
|
||||||
|
const adminDb = client.db().admin();
|
||||||
|
const serverInfo = await adminDb.serverInfo();
|
||||||
|
const serverStatus = await adminDb.serverStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: serverInfo.version || 'unknown',
|
||||||
|
uptime: serverStatus.uptime || 0,
|
||||||
|
connections: {
|
||||||
|
current: serverStatus.connections?.current || 0,
|
||||||
|
available: serverStatus.connections?.available || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting server status:', err);
|
||||||
|
return {
|
||||||
|
version: 'unknown',
|
||||||
|
uptime: 0,
|
||||||
|
connections: { current: 0, available: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
326
ts/api/handlers.s3.ts
Normal file
326
ts/api/handlers.s3.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as interfaces from '../interfaces/index.js';
|
||||||
|
import type { TsView } from '../tsview.classes.tsview.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register S3 API handlers
|
||||||
|
*/
|
||||||
|
export async function registerS3Handlers(
|
||||||
|
typedrouter: plugins.typedrequest.TypedRouter,
|
||||||
|
tsview: TsView
|
||||||
|
): Promise<void> {
|
||||||
|
// List all buckets
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListBuckets>(
|
||||||
|
'listBuckets',
|
||||||
|
async () => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { buckets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartBucket doesn't have a direct listBuckets method
|
||||||
|
// For now return empty - in a full implementation you'd use the underlying S3 client
|
||||||
|
return { buckets: [] };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create bucket
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_CreateBucket>(
|
||||||
|
'createBucket',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smartbucket.createBucket(reqData.bucketName);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating bucket:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete bucket
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeleteBucket>(
|
||||||
|
'deleteBucket',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smartbucket.removeBucket(reqData.bucketName);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting bucket:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// List objects in bucket
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListObjects>(
|
||||||
|
'listObjects',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { objects: [], prefixes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
return { objects: [], prefixes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = reqData.prefix || '';
|
||||||
|
const delimiter = reqData.delimiter || '/';
|
||||||
|
|
||||||
|
// Get the base directory or subdirectory
|
||||||
|
const baseDir = await bucket.getBaseDirectory();
|
||||||
|
let targetDir = baseDir;
|
||||||
|
|
||||||
|
if (prefix) {
|
||||||
|
// Navigate to the prefix directory
|
||||||
|
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 { objects: [], prefixes: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects: interfaces.IS3Object[] = [];
|
||||||
|
const prefixSet = new Set<string>();
|
||||||
|
|
||||||
|
// List files in current directory
|
||||||
|
const files = await targetDir.listFiles();
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = prefix + file.name;
|
||||||
|
objects.push({
|
||||||
|
key: fullPath,
|
||||||
|
isPrefix: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// List subdirectories
|
||||||
|
const dirs = await targetDir.listDirectories();
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const fullPrefix = prefix + dir.name + '/';
|
||||||
|
prefixSet.add(fullPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
prefixes: Array.from(prefixSet),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing objects:', err);
|
||||||
|
return { objects: [], prefixes: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get object content
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObject>(
|
||||||
|
'getObject',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
throw new Error('S3 not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new Error(`Bucket ${reqData.bucketName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await bucket.fastGet({ path: reqData.key });
|
||||||
|
const stats = await bucket.fastStat({ path: reqData.key });
|
||||||
|
|
||||||
|
// Determine content type from extension
|
||||||
|
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypeMap: Record<string, string> = {
|
||||||
|
'json': 'application/json',
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'html': 'text/html',
|
||||||
|
'css': 'text/css',
|
||||||
|
'js': 'application/javascript',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'xml': 'application/xml',
|
||||||
|
};
|
||||||
|
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: content.toString('base64'),
|
||||||
|
contentType,
|
||||||
|
size: stats?.ContentLength || content.length,
|
||||||
|
lastModified: stats?.LastModified?.toISOString() || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting object:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get object metadata
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObjectMetadata>(
|
||||||
|
'getObjectMetadata',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
throw new Error('S3 not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new Error(`Bucket ${reqData.bucketName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await bucket.fastStat({ path: reqData.key });
|
||||||
|
|
||||||
|
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypeMap: Record<string, string> = {
|
||||||
|
'json': 'application/json',
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'html': 'text/html',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
size: stats?.ContentLength || 0,
|
||||||
|
lastModified: stats?.LastModified?.toISOString() || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting object metadata:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put object
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_PutObject>(
|
||||||
|
'putObject',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Buffer.from(reqData.content, 'base64');
|
||||||
|
await bucket.fastPut({
|
||||||
|
path: reqData.key,
|
||||||
|
contents: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error putting object:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete object
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeleteObject>(
|
||||||
|
'deleteObject',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await bucket.fastRemove({ path: reqData.key });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting object:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy object
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_CopyObject>(
|
||||||
|
'copyObject',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceBucket = await smartbucket.getBucketByName(reqData.sourceBucket);
|
||||||
|
const destBucket = await smartbucket.getBucketByName(reqData.destBucket);
|
||||||
|
|
||||||
|
if (!sourceBucket || !destBucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from source
|
||||||
|
const content = await sourceBucket.fastGet({ path: reqData.sourceKey });
|
||||||
|
|
||||||
|
// Write to destination
|
||||||
|
await destBucket.fastPut({
|
||||||
|
path: reqData.destKey,
|
||||||
|
contents: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying object:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
2
ts/api/index.ts
Normal file
2
ts/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './handlers.s3.js';
|
||||||
|
export * from './handlers.mongodb.js';
|
||||||
11
ts/bundled_ui.ts
Normal file
11
ts/bundled_ui.ts
Normal file
File diff suppressed because one or more lines are too long
110
ts/config/classes.config.ts
Normal file
110
ts/config/classes.config.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as interfaces from '../interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration manager for tsview.
|
||||||
|
* Reads configuration from .nogit/env.json (gitzone service format)
|
||||||
|
* or accepts programmatic configuration.
|
||||||
|
*/
|
||||||
|
export class TsViewConfig {
|
||||||
|
private s3Config: interfaces.IS3Config | null = null;
|
||||||
|
private mongoConfig: interfaces.IMongoConfig | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from .nogit/env.json
|
||||||
|
* @param cwd - Working directory (defaults to process.cwd())
|
||||||
|
*/
|
||||||
|
public async loadFromEnv(cwd: string = process.cwd()): Promise<void> {
|
||||||
|
const envPath = plugins.path.join(cwd, '.nogit', 'env.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = plugins.smartfile.SmartFileFactory.nodeFs();
|
||||||
|
const envFile = await factory.fromFilePath(envPath);
|
||||||
|
var envContent = envFile.parseContentAsString();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`No .nogit/env.json found at ${envPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const envConfig: interfaces.IEnvConfig = JSON.parse(envContent);
|
||||||
|
|
||||||
|
// Parse S3 config
|
||||||
|
if (envConfig.S3_HOST || envConfig.S3_ENDPOINT) {
|
||||||
|
this.s3Config = {
|
||||||
|
endpoint: envConfig.S3_ENDPOINT || envConfig.S3_HOST || '',
|
||||||
|
port: envConfig.S3_PORT ? parseInt(envConfig.S3_PORT, 10) : undefined,
|
||||||
|
accessKey: envConfig.S3_ACCESSKEY || '',
|
||||||
|
accessSecret: envConfig.S3_SECRETKEY || '',
|
||||||
|
useSsl: envConfig.S3_USESSL === true || envConfig.S3_USESSL === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MongoDB config
|
||||||
|
if (envConfig.MONGODB_URL) {
|
||||||
|
this.mongoConfig = {
|
||||||
|
mongoDbUrl: envConfig.MONGODB_URL,
|
||||||
|
mongoDbName: envConfig.MONGODB_NAME || 'test',
|
||||||
|
};
|
||||||
|
} else if (envConfig.MONGODB_HOST) {
|
||||||
|
// Build URL from parts
|
||||||
|
const host = envConfig.MONGODB_HOST;
|
||||||
|
const port = envConfig.MONGODB_PORT || '27017';
|
||||||
|
const user = envConfig.MONGODB_USER;
|
||||||
|
const pass = envConfig.MONGODB_PASS;
|
||||||
|
const dbName = envConfig.MONGODB_NAME || 'test';
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (user && pass) {
|
||||||
|
url = `mongodb://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}`;
|
||||||
|
} else {
|
||||||
|
url = `mongodb://${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mongoConfig = {
|
||||||
|
mongoDbUrl: url,
|
||||||
|
mongoDbName: dbName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set S3 configuration programmatically
|
||||||
|
*/
|
||||||
|
public setS3Config(config: interfaces.IS3Config): void {
|
||||||
|
this.s3Config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set MongoDB configuration programmatically
|
||||||
|
*/
|
||||||
|
public setMongoConfig(config: interfaces.IMongoConfig): void {
|
||||||
|
this.mongoConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get S3 configuration
|
||||||
|
*/
|
||||||
|
public getS3Config(): interfaces.IS3Config | null {
|
||||||
|
return this.s3Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MongoDB configuration
|
||||||
|
*/
|
||||||
|
public getMongoConfig(): interfaces.IMongoConfig | null {
|
||||||
|
return this.mongoConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if S3 is configured
|
||||||
|
*/
|
||||||
|
public hasS3(): boolean {
|
||||||
|
return this.s3Config !== null && !!this.s3Config.endpoint && !!this.s3Config.accessKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if MongoDB is configured
|
||||||
|
*/
|
||||||
|
public hasMongo(): boolean {
|
||||||
|
return this.mongoConfig !== null && !!this.mongoConfig.mongoDbUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/config/index.ts
Normal file
1
ts/config/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.config.js';
|
||||||
12
ts/index.ts
Normal file
12
ts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
// Export main classes
|
||||||
|
export { TsView } from './tsview.classes.tsview.js';
|
||||||
|
export * from './interfaces/index.js';
|
||||||
|
|
||||||
|
// CLI entry point
|
||||||
|
export const runCli = async () => {
|
||||||
|
const { TsViewCli } = await import('./tsview.cli.js');
|
||||||
|
const cli = new TsViewCli();
|
||||||
|
await cli.run();
|
||||||
|
};
|
||||||
436
ts/interfaces/index.ts
Normal file
436
ts/interfaces/index.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import type * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for S3 connection
|
||||||
|
*/
|
||||||
|
export interface IS3Config {
|
||||||
|
endpoint: string;
|
||||||
|
port?: number;
|
||||||
|
accessKey: string;
|
||||||
|
accessSecret: string;
|
||||||
|
useSsl?: boolean;
|
||||||
|
region?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for MongoDB connection
|
||||||
|
*/
|
||||||
|
export interface IMongoConfig {
|
||||||
|
mongoDbUrl: string;
|
||||||
|
mongoDbName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined configuration for tsview
|
||||||
|
*/
|
||||||
|
export interface ITsViewConfig {
|
||||||
|
s3?: IS3Config;
|
||||||
|
mongo?: IMongoConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment configuration from .nogit/env.json (gitzone service format)
|
||||||
|
*/
|
||||||
|
export interface IEnvConfig {
|
||||||
|
MONGODB_URL?: string;
|
||||||
|
MONGODB_HOST?: string;
|
||||||
|
MONGODB_PORT?: string;
|
||||||
|
MONGODB_USER?: string;
|
||||||
|
MONGODB_PASS?: string;
|
||||||
|
MONGODB_NAME?: string;
|
||||||
|
S3_HOST?: string;
|
||||||
|
S3_PORT?: string;
|
||||||
|
S3_ACCESSKEY?: string;
|
||||||
|
S3_SECRETKEY?: string;
|
||||||
|
S3_BUCKET?: string;
|
||||||
|
S3_ENDPOINT?: string;
|
||||||
|
S3_USESSL?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// TypedRequest interfaces for S3 API
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
export interface IReq_ListBuckets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListBuckets
|
||||||
|
> {
|
||||||
|
method: 'listBuckets';
|
||||||
|
request: {};
|
||||||
|
response: {
|
||||||
|
buckets: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreateBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateBucket
|
||||||
|
> {
|
||||||
|
method: 'createBucket';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteBucket extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteBucket
|
||||||
|
> {
|
||||||
|
method: 'deleteBucket';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IS3Object {
|
||||||
|
key: string;
|
||||||
|
size?: number;
|
||||||
|
lastModified?: string;
|
||||||
|
isPrefix?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ListObjects extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListObjects
|
||||||
|
> {
|
||||||
|
method: 'listObjects';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
prefix?: string;
|
||||||
|
delimiter?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
objects: IS3Object[];
|
||||||
|
prefixes: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetObject
|
||||||
|
> {
|
||||||
|
method: 'getObject';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
content: string; // base64
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetObjectMetadata extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetObjectMetadata
|
||||||
|
> {
|
||||||
|
method: 'getObjectMetadata';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_PutObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PutObject
|
||||||
|
> {
|
||||||
|
method: 'putObject';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
content: string; // base64
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteObject
|
||||||
|
> {
|
||||||
|
method: 'deleteObject';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CopyObject extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CopyObject
|
||||||
|
> {
|
||||||
|
method: 'copyObject';
|
||||||
|
request: {
|
||||||
|
sourceBucket: string;
|
||||||
|
sourceKey: string;
|
||||||
|
destBucket: string;
|
||||||
|
destKey: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// TypedRequest interfaces for MongoDB API
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
export interface IMongoDatabase {
|
||||||
|
name: string;
|
||||||
|
sizeOnDisk?: number;
|
||||||
|
empty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ListDatabases extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListDatabases
|
||||||
|
> {
|
||||||
|
method: 'listDatabases';
|
||||||
|
request: {};
|
||||||
|
response: {
|
||||||
|
databases: IMongoDatabase[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMongoCollection {
|
||||||
|
name: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ListCollections extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListCollections
|
||||||
|
> {
|
||||||
|
method: 'listCollections';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
collections: IMongoCollection[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreateCollection extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateCollection
|
||||||
|
> {
|
||||||
|
method: 'createCollection';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_FindDocuments extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_FindDocuments
|
||||||
|
> {
|
||||||
|
method: 'findDocuments';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
projection?: Record<string, unknown>;
|
||||||
|
sort?: Record<string, number>;
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
documents: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetDocument extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetDocument
|
||||||
|
> {
|
||||||
|
method: 'getDocument';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
document: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_InsertDocument extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_InsertDocument
|
||||||
|
> {
|
||||||
|
method: 'insertDocument';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
insertedId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_UpdateDocument extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateDocument
|
||||||
|
> {
|
||||||
|
method: 'updateDocument';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
documentId: string;
|
||||||
|
update: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
modifiedCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteDocument extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteDocument
|
||||||
|
> {
|
||||||
|
method: 'deleteDocument';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
deletedCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RunAggregation extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RunAggregation
|
||||||
|
> {
|
||||||
|
method: 'runAggregation';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
pipeline: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
results: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMongoIndex {
|
||||||
|
name: string;
|
||||||
|
keys: Record<string, number>;
|
||||||
|
unique?: boolean;
|
||||||
|
sparse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ListIndexes extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListIndexes
|
||||||
|
> {
|
||||||
|
method: 'listIndexes';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
indexes: IMongoIndex[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreateIndex extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateIndex
|
||||||
|
> {
|
||||||
|
method: 'createIndex';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
keys: Record<string, number>;
|
||||||
|
options?: {
|
||||||
|
unique?: boolean;
|
||||||
|
sparse?: boolean;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
indexName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DropIndex extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DropIndex
|
||||||
|
> {
|
||||||
|
method: 'dropIndex';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
indexName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICollectionStats {
|
||||||
|
count: number;
|
||||||
|
size: number;
|
||||||
|
avgObjSize: number;
|
||||||
|
storageSize: number;
|
||||||
|
indexCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetCollectionStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetCollectionStats
|
||||||
|
> {
|
||||||
|
method: 'getCollectionStats';
|
||||||
|
request: {
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
stats: ICollectionStats;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetServerStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetServerStatus
|
||||||
|
> {
|
||||||
|
method: 'getServerStatus';
|
||||||
|
request: {};
|
||||||
|
response: {
|
||||||
|
version: string;
|
||||||
|
uptime: number;
|
||||||
|
connections: {
|
||||||
|
current: number;
|
||||||
|
available: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
8
ts/paths.ts
Normal file
8
ts/paths.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
export const packageDir = plugins.path.resolve(
|
||||||
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
|
'..'
|
||||||
|
);
|
||||||
|
export const tsDir = plugins.path.join(packageDir, 'ts');
|
||||||
|
export const distTsDir = plugins.path.join(packageDir, 'dist_ts');
|
||||||
45
ts/plugins.ts
Normal file
45
ts/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// node native
|
||||||
|
import * as path from 'path';
|
||||||
|
export { path };
|
||||||
|
|
||||||
|
// @push.rocks scope
|
||||||
|
import * as early from '@push.rocks/early';
|
||||||
|
early.start('tsview');
|
||||||
|
|
||||||
|
import * as npmextra from '@push.rocks/npmextra';
|
||||||
|
import * as smartbucket from '@push.rocks/smartbucket';
|
||||||
|
import * as smartcli from '@push.rocks/smartcli';
|
||||||
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
|
import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-local';
|
||||||
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
|
import * as smartopen from '@push.rocks/smartopen';
|
||||||
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
|
||||||
|
export {
|
||||||
|
early,
|
||||||
|
npmextra,
|
||||||
|
smartbucket,
|
||||||
|
smartcli,
|
||||||
|
smartdata,
|
||||||
|
smartfile,
|
||||||
|
smartlog,
|
||||||
|
smartlogDestinationLocal,
|
||||||
|
smartnetwork,
|
||||||
|
smartopen,
|
||||||
|
smartpath,
|
||||||
|
smartpromise,
|
||||||
|
};
|
||||||
|
|
||||||
|
// @api.global scope
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
|
import * as typedserver from '@api.global/typedserver';
|
||||||
|
|
||||||
|
export {
|
||||||
|
typedrequest,
|
||||||
|
typedrequestInterfaces,
|
||||||
|
typedserver,
|
||||||
|
};
|
||||||
59
ts/server/classes.viewserver.ts
Normal file
59
ts/server/classes.viewserver.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { TsView } from '../tsview.classes.tsview.js';
|
||||||
|
import { registerS3Handlers } from '../api/handlers.s3.js';
|
||||||
|
import { registerMongoHandlers } from '../api/handlers.mongodb.js';
|
||||||
|
import { files as bundledUiFiles } from '../bundled_ui.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web server for TsView that serves the bundled UI and API endpoints.
|
||||||
|
*/
|
||||||
|
export class ViewServer {
|
||||||
|
private tsview: TsView;
|
||||||
|
private port: number;
|
||||||
|
private typedServer: plugins.typedserver.TypedServer | null = null;
|
||||||
|
public typedrouter: plugins.typedrequest.TypedRouter;
|
||||||
|
|
||||||
|
constructor(tsview: TsView, port: number) {
|
||||||
|
this.tsview = tsview;
|
||||||
|
this.port = port;
|
||||||
|
this.typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
this.typedServer = new plugins.typedserver.TypedServer({
|
||||||
|
cors: true,
|
||||||
|
port: this.port,
|
||||||
|
bundledContent: bundledUiFiles,
|
||||||
|
spaFallback: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the router
|
||||||
|
this.typedServer.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
await this.typedServer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.typedServer) {
|
||||||
|
await this.typedServer.stop();
|
||||||
|
this.typedServer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/server/index.ts
Normal file
1
ts/server/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.viewserver.js';
|
||||||
137
ts/tsview.classes.tsview.ts
Normal file
137
ts/tsview.classes.tsview.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as paths from './paths.js';
|
||||||
|
import type * as interfaces from './interfaces/index.js';
|
||||||
|
import { TsViewConfig } from './config/index.js';
|
||||||
|
import { ViewServer } from './server/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main TsView class.
|
||||||
|
* Provides both CLI and programmatic access to S3 and MongoDB viewing.
|
||||||
|
*/
|
||||||
|
export class TsView {
|
||||||
|
public config: TsViewConfig;
|
||||||
|
public server: ViewServer | null = null;
|
||||||
|
|
||||||
|
private smartbucketInstance: plugins.smartbucket.SmartBucket | null = null;
|
||||||
|
private mongoDbConnection: plugins.smartdata.SmartdataDb | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = new TsViewConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from .nogit/env.json
|
||||||
|
*/
|
||||||
|
public async loadConfigFromEnv(cwd?: string): Promise<void> {
|
||||||
|
await this.config.loadFromEnv(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set S3 configuration programmatically
|
||||||
|
*/
|
||||||
|
public setS3Config(config: interfaces.IS3Config): void {
|
||||||
|
this.config.setS3Config(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set MongoDB configuration programmatically
|
||||||
|
*/
|
||||||
|
public setMongoConfig(config: interfaces.IMongoConfig): void {
|
||||||
|
this.config.setMongoConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SmartBucket instance (lazy initialization)
|
||||||
|
*/
|
||||||
|
public async getSmartBucket(): Promise<plugins.smartbucket.SmartBucket | null> {
|
||||||
|
if (this.smartbucketInstance) {
|
||||||
|
return this.smartbucketInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = this.config.getS3Config();
|
||||||
|
if (!s3Config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.smartbucketInstance = new plugins.smartbucket.SmartBucket({
|
||||||
|
endpoint: s3Config.endpoint,
|
||||||
|
port: s3Config.port,
|
||||||
|
accessKey: s3Config.accessKey,
|
||||||
|
accessSecret: s3Config.accessSecret,
|
||||||
|
useSsl: s3Config.useSsl ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.smartbucketInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the MongoDB connection (lazy initialization)
|
||||||
|
*/
|
||||||
|
public async getMongoDb(): Promise<plugins.smartdata.SmartdataDb | null> {
|
||||||
|
if (this.mongoDbConnection) {
|
||||||
|
return this.mongoDbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mongoConfig = this.config.getMongoConfig();
|
||||||
|
if (!mongoConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mongoDbConnection = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: mongoConfig.mongoDbUrl,
|
||||||
|
mongoDbName: mongoConfig.mongoDbName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.mongoDbConnection.init();
|
||||||
|
return this.mongoDbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a free port starting from the given port
|
||||||
|
*/
|
||||||
|
private async findFreePort(startPort: number = 3010): Promise<number> {
|
||||||
|
const network = new plugins.smartnetwork.SmartNetwork();
|
||||||
|
const freePort = await network.findFreePort(startPort, startPort + 100);
|
||||||
|
if (freePort === null) {
|
||||||
|
throw new Error(`No free port found between ${startPort} and ${startPort + 100}`);
|
||||||
|
}
|
||||||
|
return freePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the viewer server
|
||||||
|
* @param port - Optional port number (if not provided, finds a free port from 3010+)
|
||||||
|
*/
|
||||||
|
public async start(port?: number): Promise<number> {
|
||||||
|
const actualPort = port ?? await this.findFreePort(3010);
|
||||||
|
|
||||||
|
this.server = new ViewServer(this, actualPort);
|
||||||
|
await this.server.start();
|
||||||
|
|
||||||
|
console.log(`TsView server started on http://localhost:${actualPort}`);
|
||||||
|
|
||||||
|
// Open browser
|
||||||
|
try {
|
||||||
|
await plugins.smartopen.openUrl(`http://localhost:${actualPort}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore browser open errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the viewer server
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.stop();
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mongoDbConnection) {
|
||||||
|
await this.mongoDbConnection.close();
|
||||||
|
this.mongoDbConnection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ts/tsview.cli.ts
Normal file
127
ts/tsview.cli.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { TsView } from './tsview.classes.tsview.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI handler for tsview
|
||||||
|
*/
|
||||||
|
export class TsViewCli {
|
||||||
|
private smartcli: plugins.smartcli.Smartcli;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.smartcli = new plugins.smartcli.Smartcli();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the CLI
|
||||||
|
*/
|
||||||
|
public async run(): Promise<void> {
|
||||||
|
// Default command (no arguments)
|
||||||
|
this.smartcli.standardCommand().subscribe(async (argvArg) => {
|
||||||
|
await this.startViewer({
|
||||||
|
port: argvArg.port as number | undefined,
|
||||||
|
s3Only: false,
|
||||||
|
mongoOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// S3-only command
|
||||||
|
const s3Command = this.smartcli.addCommand('s3');
|
||||||
|
s3Command.subscribe(async (argvArg) => {
|
||||||
|
await this.startViewer({
|
||||||
|
port: argvArg.port as number | undefined,
|
||||||
|
s3Only: true,
|
||||||
|
mongoOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// MongoDB-only command
|
||||||
|
const mongoCommand = this.smartcli.addCommand('mongo');
|
||||||
|
mongoCommand.subscribe(async (argvArg) => {
|
||||||
|
await this.startViewer({
|
||||||
|
port: argvArg.port as number | undefined,
|
||||||
|
s3Only: false,
|
||||||
|
mongoOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for mongo command
|
||||||
|
this.smartcli.addCommandAlias('mongo', 'mongodb');
|
||||||
|
|
||||||
|
// Start parsing
|
||||||
|
await this.smartcli.startParse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the viewer
|
||||||
|
*/
|
||||||
|
private async startViewer(options: {
|
||||||
|
port?: number;
|
||||||
|
s3Only: boolean;
|
||||||
|
mongoOnly: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
console.log('Starting TsView...');
|
||||||
|
|
||||||
|
const viewer = new TsView();
|
||||||
|
|
||||||
|
// Load config from env.json
|
||||||
|
await viewer.loadConfigFromEnv();
|
||||||
|
|
||||||
|
// Check what's configured
|
||||||
|
const hasS3 = viewer.config.hasS3();
|
||||||
|
const hasMongo = viewer.config.hasMongo();
|
||||||
|
|
||||||
|
if (!hasS3 && !hasMongo) {
|
||||||
|
console.error('Error: No S3 or MongoDB configuration found.');
|
||||||
|
console.error('Please create .nogit/env.json with your configuration.');
|
||||||
|
console.error('');
|
||||||
|
console.error('Example .nogit/env.json:');
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
S3_ENDPOINT: 'localhost',
|
||||||
|
S3_PORT: '9000',
|
||||||
|
S3_ACCESSKEY: 'minioadmin',
|
||||||
|
S3_SECRETKEY: 'minioadmin',
|
||||||
|
S3_USESSL: false,
|
||||||
|
MONGODB_URL: 'mongodb://localhost:27017',
|
||||||
|
MONGODB_NAME: 'mydb',
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.s3Only && !hasS3) {
|
||||||
|
console.error('Error: S3 configuration not found in .nogit/env.json');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mongoOnly && !hasMongo) {
|
||||||
|
console.error('Error: MongoDB configuration not found in .nogit/env.json');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log what's available
|
||||||
|
if (hasS3) {
|
||||||
|
console.log('S3 storage configured');
|
||||||
|
}
|
||||||
|
if (hasMongo) {
|
||||||
|
console.log('MongoDB configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
const port = await viewer.start(options.port);
|
||||||
|
|
||||||
|
// Keep process running
|
||||||
|
console.log(`Press Ctrl+C to stop`);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
await viewer.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
await viewer.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ts_web/elements/index.ts
Normal file
15
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Main app shell
|
||||||
|
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
|
||||||
|
export * from './tsview-mongo-browser.js';
|
||||||
|
export * from './tsview-mongo-collections.js';
|
||||||
|
export * from './tsview-mongo-documents.js';
|
||||||
|
export * from './tsview-mongo-document.js';
|
||||||
|
export * from './tsview-mongo-indexes.js';
|
||||||
648
ts_web/elements/tsview-app.ts
Normal file
648
ts_web/elements/tsview-app.ts
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
type TViewMode = 's3' | 'mongo' | 'settings';
|
||||||
|
|
||||||
|
@customElement('tsview-app')
|
||||||
|
export class TsviewApp extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private accessor viewMode: TViewMode = 's3';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedBucket: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedDatabase: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedCollection: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor buckets: string[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor databases: Array<{ name: string; sizeOnDisk?: number }> = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor showCreateBucketDialog: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor newBucketName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor showCreateCollectionDialog: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor newCollectionName: string = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 48px 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: #16162a;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #1e1e38;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item .count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-group-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-group-header:hover {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-group-collections {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
border-color: rgba(99, 102, 241, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: #1e1e38;
|
||||||
|
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-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #16162a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: #6366f1;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn-create:hover {
|
||||||
|
background: #5558e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn-create:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadData() {
|
||||||
|
try {
|
||||||
|
// Load buckets for S3
|
||||||
|
this.buckets = await apiService.listBuckets();
|
||||||
|
|
||||||
|
// Load databases for MongoDB
|
||||||
|
this.databases = await apiService.listDatabases();
|
||||||
|
|
||||||
|
// Select first item if available
|
||||||
|
if (this.viewMode === 's3' && this.buckets.length > 0 && !this.selectedBucket) {
|
||||||
|
this.selectedBucket = this.buckets[0];
|
||||||
|
}
|
||||||
|
if (this.viewMode === 'mongo' && this.databases.length > 0 && !this.selectedDatabase) {
|
||||||
|
this.selectedDatabase = this.databases[0].name;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setViewMode(mode: TViewMode) {
|
||||||
|
this.viewMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectBucket(bucket: string) {
|
||||||
|
this.selectedBucket = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectDatabase(db: string) {
|
||||||
|
this.selectedDatabase = db;
|
||||||
|
this.selectedCollection = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectCollection(collection: string) {
|
||||||
|
this.selectedCollection = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBucket() {
|
||||||
|
if (!this.newBucketName.trim()) return;
|
||||||
|
const success = await apiService.createBucket(this.newBucketName.trim());
|
||||||
|
if (success) {
|
||||||
|
this.buckets = [...this.buckets, this.newBucketName.trim()];
|
||||||
|
this.newBucketName = '';
|
||||||
|
this.showCreateBucketDialog = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createCollection() {
|
||||||
|
if (!this.newCollectionName.trim() || !this.selectedDatabase) return;
|
||||||
|
const success = await apiService.createCollection(this.selectedDatabase, this.newCollectionName.trim());
|
||||||
|
if (success) {
|
||||||
|
this.newCollectionName = '';
|
||||||
|
this.showCreateCollectionDialog = false;
|
||||||
|
// Refresh will happen through the collections component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="app-container">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||||
|
</svg>
|
||||||
|
TsView
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button
|
||||||
|
class="nav-tab ${this.viewMode === 's3' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setViewMode('s3')}
|
||||||
|
>
|
||||||
|
S3 Storage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-tab ${this.viewMode === 'mongo' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setViewMode('mongo')}
|
||||||
|
>
|
||||||
|
MongoDB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-tab ${this.viewMode === 'settings' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setViewMode('settings')}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
${this.renderSidebar()}
|
||||||
|
${this.renderContent()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
${this.renderCreateBucketDialog()}
|
||||||
|
${this.renderCreateCollectionDialog()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCreateBucketDialog() {
|
||||||
|
if (!this.showCreateBucketDialog) return '';
|
||||||
|
return html`
|
||||||
|
<div class="dialog-overlay" @click=${() => this.showCreateBucketDialog = false}>
|
||||||
|
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="dialog-title">Create New Bucket</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dialog-input"
|
||||||
|
placeholder="Bucket name"
|
||||||
|
.value=${this.newBucketName}
|
||||||
|
@input=${(e: InputEvent) => this.newBucketName = (e.target as HTMLInputElement).value}
|
||||||
|
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createBucket()}
|
||||||
|
/>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateBucketDialog = false}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dialog-btn dialog-btn-create"
|
||||||
|
?disabled=${!this.newBucketName.trim()}
|
||||||
|
@click=${() => this.createBucket()}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCreateCollectionDialog() {
|
||||||
|
if (!this.showCreateCollectionDialog) return '';
|
||||||
|
return html`
|
||||||
|
<div class="dialog-overlay" @click=${() => this.showCreateCollectionDialog = false}>
|
||||||
|
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="dialog-title">Create Collection in ${this.selectedDatabase}</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dialog-input"
|
||||||
|
placeholder="Collection name"
|
||||||
|
.value=${this.newCollectionName}
|
||||||
|
@input=${(e: InputEvent) => this.newCollectionName = (e.target as HTMLInputElement).value}
|
||||||
|
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createCollection()}
|
||||||
|
/>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateCollectionDialog = false}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dialog-btn dialog-btn-create"
|
||||||
|
?disabled=${!this.newCollectionName.trim()}
|
||||||
|
@click=${() => this.createCollection()}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSidebar() {
|
||||||
|
if (this.viewMode === 's3') {
|
||||||
|
return html`
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">Buckets</div>
|
||||||
|
<button class="create-btn" @click=${() => this.showCreateBucketDialog = 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 Bucket
|
||||||
|
</button>
|
||||||
|
<div class="sidebar-list">
|
||||||
|
${this.buckets.length === 0
|
||||||
|
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No buckets found</div>`
|
||||||
|
: this.buckets.map(
|
||||||
|
(bucket) => html`
|
||||||
|
<div
|
||||||
|
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectBucket(bucket)}
|
||||||
|
>
|
||||||
|
${bucket}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewMode === 'mongo') {
|
||||||
|
return html`
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">Databases & Collections</div>
|
||||||
|
${this.selectedDatabase ? html`
|
||||||
|
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
New Collection
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<div class="sidebar-list">
|
||||||
|
${this.databases.length === 0
|
||||||
|
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
|
||||||
|
: this.databases.map((db) => this.renderDatabaseGroup(db))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">Settings</div>
|
||||||
|
<div class="sidebar-list">
|
||||||
|
<div class="sidebar-item">Connection</div>
|
||||||
|
<div class="sidebar-item">Display</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDatabaseGroup(db: { name: string }) {
|
||||||
|
return html`
|
||||||
|
<div class="db-group">
|
||||||
|
<div
|
||||||
|
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectDatabase(db.name)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
${db.name}
|
||||||
|
</div>
|
||||||
|
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCollectionsList(dbName: string) {
|
||||||
|
return html`
|
||||||
|
<tsview-mongo-collections
|
||||||
|
.databaseName=${dbName}
|
||||||
|
.selectedCollection=${this.selectedCollection}
|
||||||
|
@collection-selected=${(e: CustomEvent) => this.selectCollection(e.detail)}
|
||||||
|
></tsview-mongo-collections>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderContent() {
|
||||||
|
if (this.viewMode === 's3') {
|
||||||
|
if (!this.selectedBucket) {
|
||||||
|
return html`
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p>Select a bucket to browse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="content-area">
|
||||||
|
<tsview-s3-browser .bucketName=${this.selectedBucket}></tsview-s3-browser>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewMode === 'mongo') {
|
||||||
|
if (!this.selectedCollection) {
|
||||||
|
return html`
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
<p>Select a collection to view documents</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="content-area">
|
||||||
|
<tsview-mongo-browser
|
||||||
|
.databaseName=${this.selectedDatabase}
|
||||||
|
.collectionName=${this.selectedCollection}
|
||||||
|
></tsview-mongo-browser>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="content-area">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>Configuration options coming soon.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
253
ts_web/elements/tsview-mongo-browser.ts
Normal file
253
ts_web/elements/tsview-mongo-browser.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type ICollectionStats } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
type TViewTab = 'documents' | 'indexes' | 'aggregation';
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-browser')
|
||||||
|
export class TsviewMongoBrowser extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor collectionName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor activeTab: TViewTab = 'documents';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedDocumentId: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor stats: ICollectionStats | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 400px;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-panel {
|
||||||
|
overflow: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||||
|
this.loadStats();
|
||||||
|
this.selectedDocumentId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadStats() {
|
||||||
|
if (!this.databaseName || !this.collectionName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.stats = await apiService.getCollectionStats(this.databaseName, this.collectionName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading stats:', err);
|
||||||
|
this.stats = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActiveTab(tab: TViewTab) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDocumentSelected(e: CustomEvent) {
|
||||||
|
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() {
|
||||||
|
return html`
|
||||||
|
<div class="browser-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="collection-info">
|
||||||
|
<span class="collection-title">${this.collectionName}</span>
|
||||||
|
${this.stats
|
||||||
|
? html`
|
||||||
|
<div class="collection-stats">
|
||||||
|
<span class="stat-item">${this.formatCount(this.stats.count)} docs</span>
|
||||||
|
<span class="stat-item">${this.formatSize(this.stats.size)}</span>
|
||||||
|
<span class="stat-item">${this.stats.indexCount} indexes</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'documents' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setActiveTab('documents')}
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'indexes' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setActiveTab('indexes')}
|
||||||
|
>
|
||||||
|
Indexes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'aggregation' ? 'active' : ''}"
|
||||||
|
@click=${() => this.setActiveTab('aggregation')}
|
||||||
|
>
|
||||||
|
Aggregation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="main-panel">
|
||||||
|
${this.activeTab === 'documents'
|
||||||
|
? html`
|
||||||
|
<tsview-mongo-documents
|
||||||
|
.databaseName=${this.databaseName}
|
||||||
|
.collectionName=${this.collectionName}
|
||||||
|
@document-selected=${this.handleDocumentSelected}
|
||||||
|
></tsview-mongo-documents>
|
||||||
|
`
|
||||||
|
: this.activeTab === 'indexes'
|
||||||
|
? html`
|
||||||
|
<tsview-mongo-indexes
|
||||||
|
.databaseName=${this.databaseName}
|
||||||
|
.collectionName=${this.collectionName}
|
||||||
|
></tsview-mongo-indexes>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div style="padding: 24px; text-align: center; color: #666;">
|
||||||
|
Aggregation pipeline builder coming soon
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-panel">
|
||||||
|
<tsview-mongo-document
|
||||||
|
.databaseName=${this.databaseName}
|
||||||
|
.collectionName=${this.collectionName}
|
||||||
|
.documentId=${this.selectedDocumentId}
|
||||||
|
></tsview-mongo-document>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
ts_web/elements/tsview-mongo-collections.ts
Normal file
159
ts_web/elements/tsview-mongo-collections.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type IMongoCollection } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-collections')
|
||||||
|
export class TsviewMongoCollections extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor selectedCollection: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor collections: IMongoCollection[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collections-list {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadCollections();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('databaseName')) {
|
||||||
|
this.loadCollections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCollections() {
|
||||||
|
if (!this.databaseName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.collections = await apiService.listCollections(this.databaseName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading collections:', err);
|
||||||
|
this.collections = [];
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectCollection(name: string) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('collection-selected', {
|
||||||
|
detail: name,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatCount(count?: number): string {
|
||||||
|
if (count === undefined) return '';
|
||||||
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.loading) {
|
||||||
|
return html`<div class="loading-state">Loading collections...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.collections.length === 0) {
|
||||||
|
return html`<div class="empty-state">No collections</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="collections-list">
|
||||||
|
${this.collections.map(
|
||||||
|
(coll) => html`
|
||||||
|
<div
|
||||||
|
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectCollection(coll.name)}
|
||||||
|
>
|
||||||
|
<span class="collection-name">
|
||||||
|
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
${coll.name}
|
||||||
|
</span>
|
||||||
|
${coll.count !== undefined
|
||||||
|
? html`<span class="collection-count">${this.formatCount(coll.count)}</span>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
365
ts_web/elements/tsview-mongo-document.ts
Normal file
365
ts_web/elements/tsview-mongo-document.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-document')
|
||||||
|
export class TsviewMongoDocument extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor collectionName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor documentId: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor document: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor editing: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor editContent: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor error: string = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
border-color: #666;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-view {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-key {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-string {
|
||||||
|
color: #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-number {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-boolean {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-null {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-area {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 12px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-area:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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('documentId')) {
|
||||||
|
this.editing = false;
|
||||||
|
if (this.documentId) {
|
||||||
|
this.loadDocument();
|
||||||
|
} else {
|
||||||
|
this.document = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDocument() {
|
||||||
|
if (!this.documentId || !this.databaseName || !this.collectionName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.document = await apiService.getDocument(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
this.documentId
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading document:', err);
|
||||||
|
this.error = 'Failed to load document';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startEditing() {
|
||||||
|
this.editContent = JSON.stringify(this.document, null, 2);
|
||||||
|
this.editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelEditing() {
|
||||||
|
this.editing = false;
|
||||||
|
this.editContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveDocument() {
|
||||||
|
try {
|
||||||
|
const updatedDoc = JSON.parse(this.editContent);
|
||||||
|
|
||||||
|
// Remove _id from update (can't update _id)
|
||||||
|
const { _id, ...updateFields } = updatedDoc;
|
||||||
|
|
||||||
|
await apiService.updateDocument(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
this.documentId,
|
||||||
|
updateFields
|
||||||
|
);
|
||||||
|
|
||||||
|
this.editing = false;
|
||||||
|
await this.loadDocument();
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('document-updated', {
|
||||||
|
detail: { documentId: this.documentId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving document:', err);
|
||||||
|
this.error = 'Invalid JSON or save failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteDocument() {
|
||||||
|
if (!confirm('Delete this document?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.deleteDocument(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
this.documentId
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('document-deleted', {
|
||||||
|
detail: { documentId: this.documentId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.document = null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting document:', err);
|
||||||
|
this.error = 'Delete failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatJson(obj: unknown): string {
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syntaxHighlight(json: string): string {
|
||||||
|
// Basic syntax highlighting
|
||||||
|
return json
|
||||||
|
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
|
||||||
|
.replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>')
|
||||||
|
.replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
|
||||||
|
.replace(/: (true|false)/g, ': <span class="json-boolean">$1</span>')
|
||||||
|
.replace(/: (null)/g, ': <span class="json-null">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.documentId) {
|
||||||
|
return html`
|
||||||
|
<div class="document-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 document to view</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return html`
|
||||||
|
<div class="document-container">
|
||||||
|
<div class="loading-state">Loading...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error && !this.document) {
|
||||||
|
return html`
|
||||||
|
<div class="document-container">
|
||||||
|
<div class="error-state">${this.error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="document-container">
|
||||||
|
<div class="header">
|
||||||
|
<span class="header-title">Document</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
${this.editing
|
||||||
|
? html`
|
||||||
|
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
|
||||||
|
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<button class="action-btn" @click=${this.startEditing}>Edit</button>
|
||||||
|
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
${this.editing
|
||||||
|
? html`
|
||||||
|
<textarea
|
||||||
|
class="edit-area"
|
||||||
|
.value=${this.editContent}
|
||||||
|
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)}
|
||||||
|
></textarea>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div
|
||||||
|
class="json-view"
|
||||||
|
.innerHTML=${this.syntaxHighlight(this.formatJson(this.document))}
|
||||||
|
></div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
399
ts_web/elements/tsview-mongo-documents.ts
Normal file
399
ts_web/elements/tsview-mongo-documents.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-documents')
|
||||||
|
export class TsviewMongoDocuments extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor collectionName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor documents: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor total: number = 0;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor page: number = 1;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor pageSize: number = 50;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor filterText: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedId: string = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid #6366f1;
|
||||||
|
color: #818cf8;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-row {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-row.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #818cf8;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-preview {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
border-color: #666;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
color: #4ade80;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadDocuments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDocuments() {
|
||||||
|
if (!this.databaseName || !this.collectionName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
let filter = {};
|
||||||
|
if (this.filterText.trim()) {
|
||||||
|
try {
|
||||||
|
filter = JSON.parse(this.filterText);
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiService.findDocuments(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
{
|
||||||
|
filter,
|
||||||
|
skip: (this.page - 1) * this.pageSize,
|
||||||
|
limit: this.pageSize,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.documents = result.documents;
|
||||||
|
this.total = result.total;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading documents:', err);
|
||||||
|
this.documents = [];
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFilterInput(e: Event) {
|
||||||
|
this.filterText = (e.target as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFilterSubmit() {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyPress(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.handleFilterSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectDocument(doc: Record<string, unknown>) {
|
||||||
|
const id = (doc._id as string) || '';
|
||||||
|
this.selectedId = id;
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('document-selected', {
|
||||||
|
detail: { documentId: id, document: doc },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private goToPage(pageNum: number) {
|
||||||
|
this.page = pageNum;
|
||||||
|
this.loadDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDocumentPreview(doc: Record<string, unknown>): string {
|
||||||
|
const preview: Record<string, unknown> = {};
|
||||||
|
const keys = Object.keys(doc).filter((k) => k !== '_id');
|
||||||
|
for (const key of keys.slice(0, 3)) {
|
||||||
|
preview[key] = doc[key];
|
||||||
|
}
|
||||||
|
return JSON.stringify(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get totalPages(): number {
|
||||||
|
return Math.ceil(this.total / this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleInsertNew() {
|
||||||
|
const newDoc = {
|
||||||
|
// Default empty document
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const insertedId = await apiService.insertDocument(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
newDoc
|
||||||
|
);
|
||||||
|
await this.loadDocuments();
|
||||||
|
this.selectedId = insertedId;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('document-selected', {
|
||||||
|
detail: { documentId: insertedId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error inserting document:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const startRecord = (this.page - 1) * this.pageSize + 1;
|
||||||
|
const endRecord = Math.min(this.page * this.pageSize, this.total);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="documents-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="filter-input"
|
||||||
|
placeholder='Filter: {"field": "value"}'
|
||||||
|
.value=${this.filterText}
|
||||||
|
@input=${this.handleFilterInput}
|
||||||
|
@keypress=${this.handleKeyPress}
|
||||||
|
/>
|
||||||
|
<button class="filter-btn" @click=${this.handleFilterSubmit}>Apply</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-bar">
|
||||||
|
<button class="action-btn" @click=${this.handleInsertNew}>+ Insert Document</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="documents-list">
|
||||||
|
${this.loading
|
||||||
|
? html`<div class="loading-state">Loading...</div>`
|
||||||
|
: this.documents.length === 0
|
||||||
|
? html`<div class="empty-state">No documents found</div>`
|
||||||
|
: this.documents.map(
|
||||||
|
(doc) => html`
|
||||||
|
<div
|
||||||
|
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectDocument(doc)}
|
||||||
|
>
|
||||||
|
<div class="document-id">_id: ${doc._id}</div>
|
||||||
|
<div class="document-preview">${this.getDocumentPreview(doc)}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.total > 0
|
||||||
|
? html`
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Showing ${startRecord}-${endRecord} of ${this.total}
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
?disabled=${this.page <= 1}
|
||||||
|
@click=${() => this.goToPage(this.page - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
?disabled=${this.page >= this.totalPages}
|
||||||
|
@click=${() => this.goToPage(this.page + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
428
ts_web/elements/tsview-mongo-indexes.ts
Normal file
428
ts_web/elements/tsview-mongo-indexes.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type IMongoIndex } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-indexes')
|
||||||
|
export class TsviewMongoIndexes extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor collectionName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor indexes: IMongoIndex[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor showCreateDialog: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor newIndexKeys: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor newIndexUnique: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor newIndexSparse: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indexes-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
color: #4ade80;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indexes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.unique {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.sparse {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-keys {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #f87171;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog styles */
|
||||||
|
.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: #1e1e38;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.secondary:hover {
|
||||||
|
border-color: #666;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid #6366f1;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||||
|
this.loadIndexes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadIndexes() {
|
||||||
|
if (!this.databaseName || !this.collectionName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.indexes = await apiService.listIndexes(this.databaseName, this.collectionName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading indexes:', err);
|
||||||
|
this.indexes = [];
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateDialog() {
|
||||||
|
this.newIndexKeys = '';
|
||||||
|
this.newIndexUnique = false;
|
||||||
|
this.newIndexSparse = false;
|
||||||
|
this.showCreateDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeCreateDialog() {
|
||||||
|
this.showCreateDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createIndex() {
|
||||||
|
try {
|
||||||
|
const keys = JSON.parse(this.newIndexKeys);
|
||||||
|
|
||||||
|
await apiService.createIndex(this.databaseName, this.collectionName, keys, {
|
||||||
|
unique: this.newIndexUnique,
|
||||||
|
sparse: this.newIndexSparse,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.closeCreateDialog();
|
||||||
|
await this.loadIndexes();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating index:', err);
|
||||||
|
alert('Invalid JSON or index creation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dropIndex(indexName: string) {
|
||||||
|
if (indexName === '_id_') {
|
||||||
|
alert('Cannot drop the _id index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Drop index "${indexName}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.dropIndex(this.databaseName, this.collectionName, indexName);
|
||||||
|
await this.loadIndexes();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error dropping index:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatKeys(keys: Record<string, number>): string {
|
||||||
|
return JSON.stringify(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="indexes-container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="create-btn" @click=${this.openCreateDialog}>+ Create Index</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="indexes-list">
|
||||||
|
${this.loading
|
||||||
|
? html`<div class="loading-state">Loading...</div>`
|
||||||
|
: this.indexes.length === 0
|
||||||
|
? html`<div class="empty-state">No indexes found</div>`
|
||||||
|
: this.indexes.map(
|
||||||
|
(idx) => html`
|
||||||
|
<div class="index-card">
|
||||||
|
<div class="index-header">
|
||||||
|
<span class="index-name">${idx.name}</span>
|
||||||
|
<div class="index-badges">
|
||||||
|
${idx.unique ? html`<span class="badge unique">unique</span>` : ''}
|
||||||
|
${idx.sparse ? html`<span class="badge sparse">sparse</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="index-keys">${this.formatKeys(idx.keys)}</div>
|
||||||
|
<div class="index-actions">
|
||||||
|
<button
|
||||||
|
class="drop-btn"
|
||||||
|
?disabled=${idx.name === '_id_'}
|
||||||
|
@click=${() => this.dropIndex(idx.name)}
|
||||||
|
>
|
||||||
|
Drop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.showCreateDialog
|
||||||
|
? html`
|
||||||
|
<div class="dialog-overlay" @click=${this.closeCreateDialog}>
|
||||||
|
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="dialog-title">Create Index</div>
|
||||||
|
|
||||||
|
<div class="dialog-field">
|
||||||
|
<label class="dialog-label">Index Keys (JSON)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dialog-input"
|
||||||
|
placeholder='{"field": 1}'
|
||||||
|
.value=${this.newIndexKeys}
|
||||||
|
@input=${(e: Event) => (this.newIndexKeys = (e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-field">
|
||||||
|
<label class="dialog-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${this.newIndexUnique}
|
||||||
|
@change=${(e: Event) => (this.newIndexUnique = (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
Unique
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-field">
|
||||||
|
<label class="dialog-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${this.newIndexSparse}
|
||||||
|
@change=${(e: Event) => (this.newIndexSparse = (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
Sparse
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="dialog-btn secondary" @click=${this.closeCreateDialog}>Cancel</button>
|
||||||
|
<button class="dialog-btn primary" @click=${this.createIndex}>Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
ts_web/elements/tsview-s3-browser.ts
Normal file
223
ts_web/elements/tsview-s3-browser.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService } from '../services/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 = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
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(99, 102, 241, 0.2);
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
<div class="main-view">
|
||||||
|
${this.viewType === 'columns'
|
||||||
|
? html`
|
||||||
|
<tsview-s3-columns
|
||||||
|
.bucketName=${this.bucketName}
|
||||||
|
.currentPrefix=${this.currentPrefix}
|
||||||
|
@key-selected=${this.handleKeySelected}
|
||||||
|
@navigate=${this.handleNavigate}
|
||||||
|
></tsview-s3-columns>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<tsview-s3-keys
|
||||||
|
.bucketName=${this.bucketName}
|
||||||
|
.currentPrefix=${this.currentPrefix}
|
||||||
|
@key-selected=${this.handleKeySelected}
|
||||||
|
@navigate=${this.handleNavigate}
|
||||||
|
></tsview-s3-keys>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-panel">
|
||||||
|
<tsview-s3-preview
|
||||||
|
.bucketName=${this.bucketName}
|
||||||
|
.objectKey=${this.selectedKey}
|
||||||
|
></tsview-s3-preview>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
299
ts_web/elements/tsview-s3-columns.ts
Normal file
299
ts_web/elements/tsview-s3-columns.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type IS3Object } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
interface IColumn {
|
||||||
|
prefix: string;
|
||||||
|
objects: IS3Object[];
|
||||||
|
prefixes: string[];
|
||||||
|
selectedItem: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('tsview-s3-columns')
|
||||||
|
export class TsviewS3Columns extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor bucketName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor currentPrefix: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor columns: IColumn[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 280px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.folder {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item .icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item .name {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item .chevron {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadInitialColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
|
||||||
|
this.loadInitialColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInitialColumn() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/');
|
||||||
|
this.columns = [
|
||||||
|
{
|
||||||
|
prefix: this.currentPrefix,
|
||||||
|
objects: result.objects,
|
||||||
|
prefixes: result.prefixes,
|
||||||
|
selectedItem: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading objects:', err);
|
||||||
|
this.columns = [];
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectFolder(columnIndex: number, prefix: string) {
|
||||||
|
// Update selection in current column
|
||||||
|
this.columns = this.columns.map((col, i) => {
|
||||||
|
if (i === columnIndex) {
|
||||||
|
return { ...col, selectedItem: prefix };
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove columns after current
|
||||||
|
this.columns = this.columns.slice(0, columnIndex + 1);
|
||||||
|
|
||||||
|
// Load new column
|
||||||
|
try {
|
||||||
|
const result = await apiService.listObjects(this.bucketName, prefix, '/');
|
||||||
|
this.columns = [
|
||||||
|
...this.columns,
|
||||||
|
{
|
||||||
|
prefix,
|
||||||
|
objects: result.objects,
|
||||||
|
prefixes: result.prefixes,
|
||||||
|
selectedItem: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading folder:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch navigate event
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
detail: { prefix },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectFile(columnIndex: number, key: string) {
|
||||||
|
// Update selection
|
||||||
|
this.columns = this.columns.map((col, i) => {
|
||||||
|
if (i === columnIndex) {
|
||||||
|
return { ...col, selectedItem: key };
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove columns after current
|
||||||
|
this.columns = this.columns.slice(0, columnIndex + 1);
|
||||||
|
|
||||||
|
// Dispatch key-selected event
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('key-selected', {
|
||||||
|
detail: { key },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileName(path: string): string {
|
||||||
|
const parts = path.replace(/\/$/, '').split('/');
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileIcon(key: string): string {
|
||||||
|
const ext = key.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
json: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
|
||||||
|
txt: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
|
||||||
|
png: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
||||||
|
jpg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
||||||
|
jpeg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
||||||
|
gif: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
||||||
|
pdf: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
|
||||||
|
};
|
||||||
|
return iconMap[ext] || 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.loading && this.columns.length === 0) {
|
||||||
|
return html`<div class="loading">Loading...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="columns-container">
|
||||||
|
${this.columns.map((column, index) => this.renderColumn(column, index))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderColumn(column: IColumn, index: number) {
|
||||||
|
const headerName = column.prefix
|
||||||
|
? this.getFileName(column.prefix)
|
||||||
|
: this.bucketName;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="column">
|
||||||
|
<div class="column-header" title=${column.prefix || this.bucketName}>
|
||||||
|
${headerName}
|
||||||
|
</div>
|
||||||
|
<div class="column-items">
|
||||||
|
${column.prefixes.length === 0 && column.objects.length === 0
|
||||||
|
? html`<div class="empty-state">Empty folder</div>`
|
||||||
|
: ''}
|
||||||
|
${column.prefixes.map(
|
||||||
|
(prefix) => html`
|
||||||
|
<div
|
||||||
|
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectFolder(index, prefix)}
|
||||||
|
>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="name">${this.getFileName(prefix)}</span>
|
||||||
|
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
${column.objects.map(
|
||||||
|
(obj) => html`
|
||||||
|
<div
|
||||||
|
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
|
||||||
|
@click=${() => this.selectFile(index, obj.key)}
|
||||||
|
>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="${this.getFileIcon(obj.key)}" />
|
||||||
|
</svg>
|
||||||
|
<span class="name">${this.getFileName(obj.key)}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
286
ts_web/elements/tsview-s3-keys.ts
Normal file
286
ts_web/elements/tsview-s3-keys.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type IS3Object } from '../services/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-s3-keys')
|
||||||
|
export class TsviewS3Keys extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor bucketName: string = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor currentPrefix: string = '';
|
||||||
|
|
||||||
|
@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 = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
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: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #1a1a2e;
|
||||||
|
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(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadObjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
|
||||||
|
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 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() {
|
||||||
|
const filter = this.filterText.toLowerCase();
|
||||||
|
const folders = this.prefixes
|
||||||
|
.filter((p) => !filter || this.getFileName(p).toLowerCase().includes(filter))
|
||||||
|
.map((p) => ({ key: p, isFolder: true, size: undefined }));
|
||||||
|
const files = this.allKeys
|
||||||
|
.filter((o) => !filter || this.getFileName(o.key).toLowerCase().includes(filter))
|
||||||
|
.map((o) => ({ key: o.key, isFolder: false, size: o.size }));
|
||||||
|
return [...folders, ...files];
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
${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)}
|
||||||
|
>
|
||||||
|
<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">${this.getFileName(item.key)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="size-cell">
|
||||||
|
${item.isFolder ? '-' : this.formatSize(item.size)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
341
ts_web/elements/tsview-s3-preview.ts
Normal file
341
ts_web/elements/tsview-s3-preview.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService } from '../services/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 content: string = '';
|
||||||
|
|
||||||
|
@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,
|
||||||
|
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: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid #6366f1;
|
||||||
|
color: #818cf8;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-preview {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('objectKey') || changedProperties.has('bucketName')) {
|
||||||
|
if (this.objectKey) {
|
||||||
|
this.loadObject();
|
||||||
|
} else {
|
||||||
|
this.content = '';
|
||||||
|
this.contentType = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadObject() {
|
||||||
|
if (!this.objectKey || !this.bucketName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiService.getObject(this.bucketName, this.objectKey);
|
||||||
|
this.content = result.content;
|
||||||
|
this.contentType = result.contentType;
|
||||||
|
this.size = result.size;
|
||||||
|
this.lastModified = result.lastModified;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading object:', err);
|
||||||
|
this.error = 'Failed to load object';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
return atob(this.content);
|
||||||
|
} 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 = this.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 "${this.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">${this.getFileName(this.objectKey)}</div>
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span class="meta-item">${this.contentType}</span>
|
||||||
|
<span class="meta-item">${this.formatSize(this.size)}</span>
|
||||||
|
<span class="meta-item">${this.formatDate(this.lastModified)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-content">
|
||||||
|
${this.isImage()
|
||||||
|
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
|
||||||
|
: this.isText()
|
||||||
|
? html`<pre class="preview-text">${this.getTextContent()}</pre>`
|
||||||
|
: html`
|
||||||
|
<div class="binary-preview">
|
||||||
|
<p>Binary file preview not available</p>
|
||||||
|
<p>Download to view</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="action-btn" @click=${this.handleDownload}>Download</button>
|
||||||
|
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ts_web/index.ts
Normal file
28
ts_web/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
// Import all elements
|
||||||
|
import './elements/index.js';
|
||||||
|
|
||||||
|
// Import services
|
||||||
|
import { apiService } from './services/index.js';
|
||||||
|
|
||||||
|
// Initialize the application
|
||||||
|
const initApp = async () => {
|
||||||
|
console.log('TsView UI initializing...');
|
||||||
|
|
||||||
|
// Wait for custom elements to be defined
|
||||||
|
await customElements.whenDefined('tsview-app');
|
||||||
|
|
||||||
|
// Create and mount the app
|
||||||
|
const app = document.createElement('tsview-app');
|
||||||
|
document.body.appendChild(app);
|
||||||
|
|
||||||
|
console.log('TsView UI ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-init when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initApp);
|
||||||
|
} else {
|
||||||
|
initApp();
|
||||||
|
}
|
||||||
21
ts_web/plugins.ts
Normal file
21
ts_web/plugins.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// @design.estate scope
|
||||||
|
import * as deesElement from '@design.estate/dees-element';
|
||||||
|
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
export {
|
||||||
|
deesElement,
|
||||||
|
deesCatalog,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export commonly used items from dees-element
|
||||||
|
export const html = deesElement.html;
|
||||||
|
export const css = deesElement.css;
|
||||||
|
export const cssManager = deesElement.cssManager;
|
||||||
|
export const customElement = deesElement.customElement;
|
||||||
|
export const property = deesElement.property;
|
||||||
|
export const state = deesElement.state;
|
||||||
|
export const DeesElement = deesElement.DeesElement;
|
||||||
|
|
||||||
|
// @api.global scope
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
export { typedrequest };
|
||||||
307
ts_web/services/api.service.ts
Normal file
307
ts_web/services/api.service.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
// Import interfaces from shared types
|
||||||
|
// Note: In bundled form these are inlined
|
||||||
|
export interface IS3Object {
|
||||||
|
key: string;
|
||||||
|
size?: number;
|
||||||
|
lastModified?: string;
|
||||||
|
isPrefix?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMongoDatabase {
|
||||||
|
name: string;
|
||||||
|
sizeOnDisk?: number;
|
||||||
|
empty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMongoCollection {
|
||||||
|
name: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMongoIndex {
|
||||||
|
name: string;
|
||||||
|
keys: Record<string, number>;
|
||||||
|
unique?: boolean;
|
||||||
|
sparse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICollectionStats {
|
||||||
|
count: number;
|
||||||
|
size: number;
|
||||||
|
avgObjSize: number;
|
||||||
|
storageSize: number;
|
||||||
|
indexCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API service for communicating with the tsview backend
|
||||||
|
*/
|
||||||
|
export class ApiService {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Use current origin for API calls
|
||||||
|
this.baseUrl = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a typed request to the backend
|
||||||
|
*/
|
||||||
|
private async request<TReq, TRes>(method: string, requestData: TReq): Promise<TRes> {
|
||||||
|
const typedRequest = new plugins.typedrequest.TypedRequest<{
|
||||||
|
method: string;
|
||||||
|
request: TReq;
|
||||||
|
response: TRes;
|
||||||
|
}>(this.baseUrl, method);
|
||||||
|
|
||||||
|
const response = await typedRequest.fire(requestData);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// S3 API Methods
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
async listBuckets(): Promise<string[]> {
|
||||||
|
const result = await this.request<{}, { buckets: string[] }>('listBuckets', {});
|
||||||
|
return result.buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBucket(bucketName: string): Promise<boolean> {
|
||||||
|
const result = await this.request<{ bucketName: string }, { success: boolean }>(
|
||||||
|
'createBucket',
|
||||||
|
{ bucketName }
|
||||||
|
);
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBucket(bucketName: string): Promise<boolean> {
|
||||||
|
const result = await this.request<{ bucketName: string }, { success: boolean }>(
|
||||||
|
'deleteBucket',
|
||||||
|
{ bucketName }
|
||||||
|
);
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listObjects(
|
||||||
|
bucketName: string,
|
||||||
|
prefix?: string,
|
||||||
|
delimiter?: string
|
||||||
|
): Promise<{ objects: IS3Object[]; prefixes: string[] }> {
|
||||||
|
return this.request('listObjects', { bucketName, prefix, delimiter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObject(
|
||||||
|
bucketName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<{ content: string; contentType: string; size: number; lastModified: string }> {
|
||||||
|
return this.request('getObject', { bucketName, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectMetadata(
|
||||||
|
bucketName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<{ contentType: string; size: number; lastModified: string }> {
|
||||||
|
return this.request('getObjectMetadata', { bucketName, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
async putObject(
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
content: string,
|
||||||
|
contentType: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ bucketName: string; key: string; content: string; contentType: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('putObject', { bucketName, key, content, contentType });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteObject(bucketName: string, key: string): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ bucketName: string; key: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('deleteObject', { bucketName, key });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyObject(
|
||||||
|
sourceBucket: string,
|
||||||
|
sourceKey: string,
|
||||||
|
destBucket: string,
|
||||||
|
destKey: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ sourceBucket: string; sourceKey: string; destBucket: string; destKey: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('copyObject', { sourceBucket, sourceKey, destBucket, destKey });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// MongoDB API Methods
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
async listDatabases(): Promise<IMongoDatabase[]> {
|
||||||
|
const result = await this.request<{}, { databases: IMongoDatabase[] }>(
|
||||||
|
'listDatabases',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return result.databases;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCollections(databaseName: string): Promise<IMongoCollection[]> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string },
|
||||||
|
{ collections: IMongoCollection[] }
|
||||||
|
>('listCollections', { databaseName });
|
||||||
|
return result.collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCollection(databaseName: string, collectionName: string): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('createCollection', { databaseName, collectionName });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDocuments(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
options?: {
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
projection?: Record<string, unknown>;
|
||||||
|
sort?: Record<string, number>;
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
): Promise<{ documents: Record<string, unknown>[]; total: number }> {
|
||||||
|
return this.request('findDocuments', {
|
||||||
|
databaseName,
|
||||||
|
collectionName,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocument(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string; documentId: string },
|
||||||
|
{ document: Record<string, unknown> | null }
|
||||||
|
>('getDocument', { databaseName, collectionName, documentId });
|
||||||
|
return result.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertDocument(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
document: Record<string, unknown>
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string; document: Record<string, unknown> },
|
||||||
|
{ insertedId: string }
|
||||||
|
>('insertDocument', { databaseName, collectionName, document });
|
||||||
|
return result.insertedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDocument(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string,
|
||||||
|
update: Record<string, unknown>
|
||||||
|
): Promise<{ success: boolean; modifiedCount: number }> {
|
||||||
|
return this.request('updateDocument', {
|
||||||
|
databaseName,
|
||||||
|
collectionName,
|
||||||
|
documentId,
|
||||||
|
update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDocument(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string
|
||||||
|
): Promise<{ success: boolean; deletedCount: number }> {
|
||||||
|
return this.request('deleteDocument', { databaseName, collectionName, documentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAggregation(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
pipeline: Record<string, unknown>[]
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string; pipeline: Record<string, unknown>[] },
|
||||||
|
{ results: Record<string, unknown>[] }
|
||||||
|
>('runAggregation', { databaseName, collectionName, pipeline });
|
||||||
|
return result.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIndexes(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string
|
||||||
|
): Promise<IMongoIndex[]> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string },
|
||||||
|
{ indexes: IMongoIndex[] }
|
||||||
|
>('listIndexes', { databaseName, collectionName });
|
||||||
|
return result.indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIndex(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
keys: Record<string, number>,
|
||||||
|
options?: { unique?: boolean; sparse?: boolean; name?: string }
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await this.request<
|
||||||
|
{
|
||||||
|
databaseName: string;
|
||||||
|
collectionName: string;
|
||||||
|
keys: Record<string, number>;
|
||||||
|
options?: { unique?: boolean; sparse?: boolean; name?: string };
|
||||||
|
},
|
||||||
|
{ indexName: string }
|
||||||
|
>('createIndex', { databaseName, collectionName, keys, options });
|
||||||
|
return result.indexName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dropIndex(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string,
|
||||||
|
indexName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string; indexName: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('dropIndex', { databaseName, collectionName, indexName });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCollectionStats(
|
||||||
|
databaseName: string,
|
||||||
|
collectionName: string
|
||||||
|
): Promise<ICollectionStats> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ databaseName: string; collectionName: string },
|
||||||
|
{ stats: ICollectionStats }
|
||||||
|
>('getCollectionStats', { databaseName, collectionName });
|
||||||
|
return result.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerStatus(): Promise<{
|
||||||
|
version: string;
|
||||||
|
uptime: number;
|
||||||
|
connections: { current: number; available: number };
|
||||||
|
}> {
|
||||||
|
return this.request('getServerStatus', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ts_web/services/index.ts
Normal file
4
ts_web/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './api.service.js';
|
||||||
|
import { ApiService } from './api.service.js';
|
||||||
|
|
||||||
|
export const apiService = new ApiService();
|
||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {}
|
||||||
|
},
|
||||||
|
"exclude": ["dist_*/**/*.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user