feat(ui): add browser console served by the daemon
Introduce a minimal operations console reachable on a dedicated UI port (default 8081), kept separate from the OpenAI-compatible API port. - ts_web/ holds the SPA shell (index.html, app.css, vanilla app.js) with sidebar navigation for all views from readme.ui.md and a working Overview page backed by a new /_ui/overview JSON endpoint. - scripts/bundle-ui.ts walks ts_web/ and emits ts_bundled/bundle.ts, a single generated module exporting every asset as base64. Mirrors the @stack.gallery/registry pattern so deno compile binaries embed the entire UI with no external filesystem dependency at runtime. - ts/ui/server.ts (UiServer) serves assets from either the bundled map (default, prod) or directly from ts_web/ on disk (dev). The source is chosen per-config and can be overridden by UI_ASSET_SOURCE=disk|bundle. SPA fallback routes unknown extensionless paths to index.html. - IModelGridConfig.ui block with enabled/port/host/assetSource defaults; config init writes the block, the normalizer fills in defaults on load, and the daemon starts/stops the UI server alongside the API. - deno.json gains a bundle:ui task; compile:all now depends on it so released binaries always contain an up-to-date bundle. dev task sets UI_ASSET_SOURCE=disk for hot edits. - ts_bundled/ is gitignored (generated on build). - test/ui-server.smoke.ts exercises bundle and disk modes end to end (index, app.js, SPA fallback, /_ui/overview, 404).
This commit is contained in:
@@ -218,6 +218,12 @@ export class ConfigHandler {
|
||||
cors: true,
|
||||
corsOrigins: ['*'],
|
||||
},
|
||||
ui: {
|
||||
enabled: true,
|
||||
port: 8081,
|
||||
host: '0.0.0.0',
|
||||
assetSource: 'bundle',
|
||||
},
|
||||
docker: {
|
||||
networkName: 'modelgrid',
|
||||
runtime: 'docker',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from './logger.ts';
|
||||
import { TIMING } from './constants.ts';
|
||||
import type { ModelGrid } from './modelgrid.ts';
|
||||
import { ApiServer } from './api/server.ts';
|
||||
import { UiServer } from './ui/server.ts';
|
||||
import type { IModelGridConfig } from './interfaces/config.ts';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ export class Daemon {
|
||||
private modelgrid: ModelGrid;
|
||||
private isRunning: boolean = false;
|
||||
private apiServer?: ApiServer;
|
||||
private uiServer?: UiServer;
|
||||
|
||||
constructor(modelgrid: ModelGrid) {
|
||||
this.modelgrid = modelgrid;
|
||||
@@ -48,6 +50,9 @@ export class Daemon {
|
||||
// Start API server
|
||||
await this.startApiServer(config);
|
||||
|
||||
// Start UI server (runs on its own port, serves the operations console)
|
||||
await this.startUiServer(config);
|
||||
|
||||
// Start containers
|
||||
await this.startContainers();
|
||||
|
||||
@@ -86,6 +91,11 @@ export class Daemon {
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
// Stop UI server
|
||||
if (this.uiServer) {
|
||||
await this.uiServer.stop();
|
||||
}
|
||||
|
||||
// Stop API server
|
||||
if (this.apiServer) {
|
||||
await this.apiServer.stop();
|
||||
@@ -114,6 +124,26 @@ export class Daemon {
|
||||
await this.apiServer.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the UI server, if enabled.
|
||||
*/
|
||||
private async startUiServer(config: IModelGridConfig): Promise<void> {
|
||||
if (!config.ui.enabled) {
|
||||
logger.dim('UI server disabled in configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Starting UI server...');
|
||||
|
||||
this.uiServer = new UiServer(
|
||||
config.ui,
|
||||
this.modelgrid.getContainerManager(),
|
||||
this.modelgrid.getClusterManager(),
|
||||
);
|
||||
|
||||
await this.uiServer.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start configured containers
|
||||
*/
|
||||
|
||||
@@ -60,6 +60,28 @@ export interface IModelConfig {
|
||||
autoLoad: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-based operations console (UI) configuration.
|
||||
* The UI is served on its own port, distinct from the OpenAI API port,
|
||||
* so that the data plane stays clean.
|
||||
*/
|
||||
export interface IUiConfig {
|
||||
/** Whether to start the UI server alongside the API */
|
||||
enabled: boolean;
|
||||
/** Port to bind the UI server to (default: 8081) */
|
||||
port: number;
|
||||
/** Host to bind the UI server to (default: '0.0.0.0') */
|
||||
host: string;
|
||||
/**
|
||||
* Where UI assets come from.
|
||||
* - 'bundle': from the compiled-in `ts_bundled/bundle.ts` (default, required
|
||||
* for `deno compile` single-binary builds)
|
||||
* - 'disk': read on demand from `ts_web/` for the dev loop
|
||||
* Overridden at runtime by the `UI_ASSET_SOURCE` env var.
|
||||
*/
|
||||
assetSource: 'bundle' | 'disk';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ModelGrid configuration interface
|
||||
*/
|
||||
@@ -68,6 +90,8 @@ export interface IModelGridConfig {
|
||||
version: string;
|
||||
/** API server configuration */
|
||||
api: IApiConfig;
|
||||
/** UI server configuration */
|
||||
ui: IUiConfig;
|
||||
/** Docker configuration */
|
||||
docker: IDockerConfig;
|
||||
/** GPU configuration */
|
||||
|
||||
@@ -316,6 +316,12 @@ export class ModelGrid {
|
||||
cors: config.api?.cors ?? true,
|
||||
corsOrigins: config.api?.corsOrigins || ['*'],
|
||||
},
|
||||
ui: {
|
||||
enabled: config.ui?.enabled ?? true,
|
||||
port: config.ui?.port || 8081,
|
||||
host: config.ui?.host || '0.0.0.0',
|
||||
assetSource: config.ui?.assetSource === 'disk' ? 'disk' : 'bundle',
|
||||
},
|
||||
docker: {
|
||||
networkName: config.docker?.networkName || 'modelgrid',
|
||||
runtime: config.docker?.runtime || 'docker',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { UiServer } from './server.ts';
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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<string, IAssetEntry> | 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<void> {
|
||||
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<void>((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<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
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<string, 'healthy' | 'unhealthy'> = {};
|
||||
const gpuStatus: Record<string, 'available' | 'in_use' | 'error'> = {};
|
||||
|
||||
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<void> {
|
||||
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<Map<string, IAssetEntry> | 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<string, IAssetEntry>();
|
||||
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<string, string> = {
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user