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