/** * UI Server * * Serves the ModelGrid operations console on its own port, separate from * the OpenAI-compatible API. Assets come from one of two sources: * - 'disk': read on demand from `ts_web/` (dev loop, hot edits) * - 'bundle': from the generated `ts_bundled/bundle.ts` module * (default, required for `deno compile` single-binary builds) * * Plus a single JSON endpoint `/_ui/overview` that the SPA calls to render * the Overview view without cross-origin fetches into the API server. */ import * as http from 'node:http'; import * as fs from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { IUiConfig } from '../interfaces/config.ts'; import type { IHealthResponse } from '../interfaces/api.ts'; import { logger } from '../logger.ts'; import { VERSION } from '../constants.ts'; import type { ContainerManager } from '../containers/container-manager.ts'; import type { ClusterManager } from '../cluster/cluster-manager.ts'; import { GpuDetector } from '../hardware/gpu-detector.ts'; interface IBundledFile { path: string; contentBase64: string; } interface IAssetEntry { bytes: Uint8Array; contentType: string; } const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = resolve(__dirname, '..', '..'); const TS_WEB_DIR = join(REPO_ROOT, 'ts_web'); export class UiServer { private server?: http.Server; private config: IUiConfig; private containerManager: ContainerManager; private clusterManager: ClusterManager; private gpuDetector: GpuDetector; private bundleMap: Map | null = null; private activeAssetSource: 'disk' | 'bundle' = 'bundle'; private startTime = 0; constructor( config: IUiConfig, containerManager: ContainerManager, clusterManager: ClusterManager, ) { this.config = config; this.containerManager = containerManager; this.clusterManager = clusterManager; this.gpuDetector = new GpuDetector(); } public async start(): Promise { if (this.server) { logger.warn('UI server is already running'); return; } this.activeAssetSource = this.resolveAssetSource(); if (this.activeAssetSource === 'bundle') { this.bundleMap = await this.loadBundleMap(); if (!this.bundleMap) { logger.warn( 'UI bundle not found (ts_bundled/bundle.ts missing). ' + 'Falling back to disk mode — run `deno task bundle:ui` before `deno compile`.', ); this.activeAssetSource = 'disk'; } } this.startTime = Date.now(); this.server = http.createServer(async (req, res) => { try { await this.handleRequest(req, res); } catch (err) { logger.error(`UI request error: ${err instanceof Error ? err.message : String(err)}`); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal server error'); } } }); await new Promise((resolve, reject) => { this.server!.listen(this.config.port, this.config.host, () => { logger.success( `UI server started on ${this.config.host}:${this.config.port} ` + `(asset source: ${this.activeAssetSource})`, ); resolve(); }); this.server!.on('error', (error) => { logger.error(`UI server error: ${error.message}`); reject(error); }); }); } public async stop(): Promise { if (!this.server) return; await new Promise((resolve) => { this.server!.close(() => resolve()); }); this.server = undefined; logger.log('UI server stopped'); } public getInfo(): { running: boolean; host: string; port: number; assetSource: string } { return { running: !!this.server, host: this.config.host, port: this.config.port, assetSource: this.activeAssetSource, }; } private async handleRequest( req: http.IncomingMessage, res: http.ServerResponse, ): Promise { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const path = url.pathname; if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { 'Content-Type': 'text/plain', 'Allow': 'GET, HEAD' }); res.end('Method Not Allowed'); return; } if (path === '/_ui/overview') { await this.handleOverview(res); return; } await this.serveAsset(path, res); } private async handleOverview(res: http.ServerResponse): Promise { const statuses = await this.containerManager.getAllStatus(); const models = await this.containerManager.getAllAvailableModels(); const gpus = await this.gpuDetector.detectGpus(); let status: 'ok' | 'degraded' | 'error' = 'ok'; const containerHealth: Record = {}; const gpuStatus: Record = {}; for (const [id, s] of statuses) { if (s.running && s.health === 'healthy') { containerHealth[id] = 'healthy'; } else { containerHealth[id] = 'unhealthy'; status = 'degraded'; } } for (const gpu of gpus) { gpuStatus[gpu.id] = 'available'; } const health: IHealthResponse = { status, version: VERSION, uptime: Math.floor((Date.now() - this.startTime) / 1000), containers: statuses.size, models: models.size, gpus: gpus.length, details: { containers: containerHealth, gpus: gpuStatus, }, }; const clusterConfig = this.clusterManager.getConfig(); const body = { health, node: { name: clusterConfig?.nodeName ?? 'modelgrid-local', role: clusterConfig?.role ?? 'standalone', version: VERSION, }, }; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }); res.end(JSON.stringify(body)); } private async serveAsset(path: string, res: http.ServerResponse): Promise { const normalized = path === '/' ? '/index.html' : path; if (this.activeAssetSource === 'bundle' && this.bundleMap) { const hit = this.bundleMap.get(normalized); if (hit) { this.writeAsset(res, hit); return; } // SPA fallback: any unknown non-asset path gets index.html. if (!hasKnownAssetExtension(normalized)) { const shell = this.bundleMap.get('/index.html'); if (shell) { this.writeAsset(res, shell); return; } } res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); return; } // Disk mode const safe = normalizePath(normalized); if (!safe) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Bad Request'); return; } const full = join(TS_WEB_DIR, safe); try { const bytes = await fs.readFile(full); this.writeAsset(res, { bytes: new Uint8Array(bytes), contentType: contentTypeForPath(safe), }); return; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { if (!hasKnownAssetExtension(safe)) { try { const shell = await fs.readFile(join(TS_WEB_DIR, 'index.html')); this.writeAsset(res, { bytes: new Uint8Array(shell), contentType: 'text/html; charset=utf-8', }); return; } catch { // fall through to 404 } } res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); return; } throw err; } } private writeAsset(res: http.ServerResponse, asset: IAssetEntry): void { res.writeHead(200, { 'Content-Type': asset.contentType, 'Content-Length': asset.bytes.byteLength, 'Cache-Control': 'no-cache', }); res.end(asset.bytes); } private resolveAssetSource(): 'disk' | 'bundle' { const envOverride = typeof Deno !== 'undefined' ? Deno.env.get('UI_ASSET_SOURCE') : undefined; const picked = (envOverride || this.config.assetSource || 'bundle').toLowerCase(); if (picked === 'disk' || picked === 'bundle') return picked; logger.warn(`Unknown UI_ASSET_SOURCE "${picked}", defaulting to bundle`); return 'bundle'; } private async loadBundleMap(): Promise | null> { try { // The bundle module is generated by `deno task bundle:ui`. // @ts-ignore — generated file may not exist until the bundle task runs. const mod = await import('../../ts_bundled/bundle.ts'); const files: IBundledFile[] = mod.files ?? []; const map = new Map(); for (const file of files) { map.set(`/${file.path}`, { bytes: decodeBase64(file.contentBase64), contentType: contentTypeForPath(file.path), }); } return map; } catch { return null; } } } function decodeBase64(input: string): Uint8Array { const binary = atob(input); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function normalizePath(path: string): string | null { // Strip leading slashes, reject traversal. const stripped = path.replace(/^\/+/, ''); if (stripped.includes('..')) return null; return stripped; } function hasKnownAssetExtension(path: string): boolean { return extname(path) !== ''; } function contentTypeForPath(path: string): string { const ext = extname(path).toLowerCase().replace(/^\./, ''); const types: Record = { html: 'text/html; charset=utf-8', js: 'application/javascript; charset=utf-8', mjs: 'application/javascript; charset=utf-8', css: 'text/css; charset=utf-8', json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', ico: 'image/x-icon', webp: 'image/webp', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', txt: 'text/plain; charset=utf-8', }; return types[ext] || 'application/octet-stream'; }