feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend

This commit is contained in:
2026-03-20 16:43:44 +00:00
parent 0fc74ff995
commit d4f758ce0f
159 changed files with 12465 additions and 14861 deletions

View File

@@ -4,12 +4,46 @@
*/
import * as plugins from './plugins.ts';
import { initDb, closeDb, isDbConnected } from './models/db.ts';
import { closeDb, initDb, isDbConnected } from './models/db.ts';
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
import { ApiRouter } from './api/router.ts';
import { getEmbeddedFile } from './embedded-ui.generated.ts';
import { ReloadSocketManager } from './reload-socket.ts';
import { OpsServer } from './opsserver/classes.opsserver.ts';
// Bundled UI files (generated by tsbundle with base64ts output mode)
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
try {
// @ts-ignore - generated file may not exist yet
const { files } = await import('../ts_bundled/bundle.ts');
bundledFileMap = new Map();
for (const file of files as Array<{ path: string; contentBase64: string }>) {
const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
const ext = file.path.split('.').pop() || '';
bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) });
}
} catch {
console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)');
}
function getContentType(ext: string): string {
const types: Record<string, string> = {
html: 'text/html',
js: 'application/javascript',
css: 'text/css',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
eot: 'application/vnd.ms-fontobject',
map: 'application/json',
};
return types[ext] || 'application/octet-stream';
}
export interface IRegistryConfig {
// MongoDB configuration
@@ -42,8 +76,7 @@ export class StackGalleryRegistry {
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
private authProvider: StackGalleryAuthProvider | null = null;
private storageHooks: StackGalleryStorageHooks | null = null;
private apiRouter: ApiRouter | null = null;
private reloadSocket: ReloadSocketManager | null = null;
private opsServer: OpsServer | null = null;
private isInitialized = false;
constructor(config: IRegistryConfig) {
@@ -115,13 +148,11 @@ export class StackGalleryRegistry {
});
console.log('[StackGalleryRegistry] smartregistry initialized');
// Initialize API router
console.log('[StackGalleryRegistry] Initializing API router...');
this.apiRouter = new ApiRouter();
console.log('[StackGalleryRegistry] API router initialized');
// Initialize reload socket for hot reload
this.reloadSocket = new ReloadSocketManager();
// Initialize OpsServer (TypedRequest handlers)
console.log('[StackGalleryRegistry] Initializing OpsServer...');
this.opsServer = new OpsServer(this);
await this.opsServer.start();
console.log('[StackGalleryRegistry] OpsServer initialized');
this.isInitialized = true;
console.log('[StackGalleryRegistry] Initialization complete');
@@ -144,7 +175,7 @@ export class StackGalleryRegistry {
{ port, hostname: host },
async (request: Request): Promise<Response> => {
return await this.handleRequest(request);
}
},
);
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
@@ -162,11 +193,14 @@ export class StackGalleryRegistry {
return this.healthCheck();
}
// API endpoints (handled by REST API layer)
if (path.startsWith('/api/')) {
return await this.handleApiRequest(request);
// TypedRequest endpoint (handled by OpsServer TypedRouter)
if (path === '/typedrequest' && request.method === 'POST') {
return await this.handleTypedRequest(request);
}
// Legacy REST API endpoints (keep for backwards compatibility during migration)
// TODO: Remove once frontend is fully migrated to TypedRequest
// Registry protocol endpoints (handled by smartregistry)
const registryPaths = [
'/-/',
@@ -180,8 +214,7 @@ export class StackGalleryRegistry {
'/api/v1/gems/',
'/gems/',
];
const isRegistryPath =
registryPaths.some((p) => path.startsWith(p)) ||
const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) ||
(path.startsWith('/@') && !path.startsWith('/@stack'));
if (this.smartRegistry && isRegistryPath) {
@@ -199,11 +232,6 @@ export class StackGalleryRegistry {
}
}
// WebSocket upgrade for hot reload
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
return this.reloadSocket!.handleUpgrade(request);
}
// Serve static UI files
return this.serveStaticFile(path);
}
@@ -212,7 +240,7 @@ export class StackGalleryRegistry {
* Convert a Deno Request to smartregistry IRequestContext
*/
private async requestToContext(
request: Request
request: Request,
): Promise<plugins.smartregistry.IRequestContext> {
const url = new URL(request.url);
const headers: Record<string, string> = {};
@@ -285,24 +313,28 @@ export class StackGalleryRegistry {
}
/**
* Serve static files from embedded UI
* Serve static files from bundled UI
*/
private serveStaticFile(path: string): Response {
if (!bundledFileMap) {
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
}
const filePath = path === '/' ? '/index.html' : path;
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data as unknown as BodyInit, {
// Get bundled file
const file = bundledFileMap.get(filePath);
if (file) {
return new Response(file.data, {
status: 200,
headers: { 'Content-Type': embeddedFile.contentType },
headers: { 'Content-Type': file.contentType },
});
}
// SPA fallback: serve index.html for unknown paths
const indexFile = getEmbeddedFile('/index.html');
const indexFile = bundledFileMap.get('/index.html');
if (indexFile) {
return new Response(indexFile.data as unknown as BodyInit, {
return new Response(indexFile.data, {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
@@ -312,17 +344,34 @@ export class StackGalleryRegistry {
}
/**
* Handle API requests
* Handle TypedRequest calls
*/
private async handleApiRequest(request: Request): Promise<Response> {
if (!this.apiRouter) {
return new Response(JSON.stringify({ error: 'API router not initialized' }), {
private async handleTypedRequest(request: Request): Promise<Response> {
if (!this.opsServer) {
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return await this.apiRouter.handle(request);
try {
const body = await request.json();
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('[StackGalleryRegistry] TypedRequest error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(
JSON.stringify({ error: message }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
}
/**
@@ -352,6 +401,9 @@ export class StackGalleryRegistry {
*/
public async stop(): Promise<void> {
console.log('[StackGalleryRegistry] Shutting down...');
if (this.opsServer) {
await this.opsServer.stop();
}
await closeDb();
this.isInitialized = false;
console.log('[StackGalleryRegistry] Shutdown complete');
@@ -420,9 +472,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
const config: IRegistryConfig = {
mongoUrl:
env.MONGODB_URL ||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
mongoUrl: env.MONGODB_URL ||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${
env.MONGODB_PORT || '27017'
}/${env.MONGODB_NAME}?authSource=admin`,
mongoDb: env.MONGODB_NAME || 'stackgallery',
s3Endpoint: s3Endpoint,
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
@@ -444,7 +497,7 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
} else {
console.warn(
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
error
error,
);
}
return createRegistryFromEnv();