This commit is contained in:
2026-01-23 22:15:51 +00:00
commit 74d24cf8b9
44 changed files with 15483 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

49
readme.hints.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export * from './handlers.s3.js';
export * from './handlers.mongodb.js';

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
View 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
View File

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

12
ts/index.ts Normal file
View 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
View 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
View 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
View 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,
};

View 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
View File

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

137
ts/tsview.classes.tsview.ts Normal file
View 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
View 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
View 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';

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

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

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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
View 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
View 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 };

View 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
View 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
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
},
"exclude": ["dist_*/**/*.d.ts"]
}