diff --git a/.gitignore b/.gitignore index f535268..fbb5dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Compiled Deno binaries (built by scripts/compile-all.sh) dist/binaries/ +# Generated UI bundle (built by scripts/bundle-ui.ts) +ts_bundled/ + # Deno cache and lock file .deno/ deno.lock diff --git a/deno.json b/deno.json index 2c5473c..13b3e87 100644 --- a/deno.json +++ b/deno.json @@ -4,9 +4,10 @@ "exports": "./mod.ts", "nodeModulesDir": "auto", "tasks": { - "dev": "deno run --allow-all mod.ts", + "dev": "UI_ASSET_SOURCE=disk deno run --allow-all mod.ts", + "bundle:ui": "deno run --allow-read --allow-write scripts/bundle-ui.ts", "compile": "deno task compile:all", - "compile:all": "bash scripts/compile-all.sh", + "compile:all": "deno task bundle:ui && bash scripts/compile-all.sh", "test": "deno test --allow-all test/", "test:watch": "deno test --allow-all --watch test/", "check": "deno check mod.ts", diff --git a/scripts/bundle-ui.ts b/scripts/bundle-ui.ts new file mode 100644 index 0000000..6af3ccc --- /dev/null +++ b/scripts/bundle-ui.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write + +/** + * bundle-ui.ts + * + * Walks `ts_web/` and emits `ts_bundled/bundle.ts`, a single TypeScript + * module that exports every UI asset as base64 in order. The daemon's + * UI server imports this module at runtime to serve the console without + * any external filesystem dependency — the entire browser app ends up + * embedded in the `deno compile` binary. + * + * The output shape matches the `@stack.gallery/registry` convention so + * a consumer can loop `files` as `{ path, contentBase64 }` entries. + */ + +import { walk } from 'jsr:@std/fs@^1.0.0/walk'; +import { fromFileUrl, join, relative } from 'jsr:@std/path@^1.0.0'; + +const here = fromFileUrl(new URL('./', import.meta.url)); +const repoRoot = join(here, '..'); +const sourceDir = join(repoRoot, 'ts_web'); +const outDir = join(repoRoot, 'ts_bundled'); +const outFile = join(outDir, 'bundle.ts'); + +async function main(): Promise { + const entries: Array<{ path: string; contentBase64: string; size: number }> = []; + + for await ( + const entry of walk(sourceDir, { + includeDirs: false, + includeSymlinks: false, + }) + ) { + const rel = relative(sourceDir, entry.path).replaceAll('\\', '/'); + const bytes = await Deno.readFile(entry.path); + entries.push({ + path: rel, + contentBase64: encodeBase64(bytes), + size: bytes.byteLength, + }); + } + + entries.sort((a, b) => a.path.localeCompare(b.path)); + + const generatedAt = new Date().toISOString(); + const totalBytes = entries.reduce((sum, e) => sum + e.size, 0); + + const header = [ + '// AUTO-GENERATED — do not edit.', + '// Regenerate with: deno task bundle:ui', + `// Source: ts_web/ (${entries.length} files, ${totalBytes} bytes)`, + `// Generated: ${generatedAt}`, + '', + 'export interface IBundledFile {', + ' path: string;', + ' contentBase64: string;', + '}', + '', + 'export const files: IBundledFile[] = [', + ].join('\n'); + + const body = entries.map((e) => + ` { path: ${JSON.stringify(e.path)}, contentBase64: ${JSON.stringify(e.contentBase64)} },` + ).join('\n'); + + const footer = '\n];\n'; + + await Deno.mkdir(outDir, { recursive: true }); + await Deno.writeTextFile(outFile, header + '\n' + body + footer); + + console.log( + `bundle-ui: wrote ${entries.length} file(s), ${totalBytes} bytes → ${ + relative(repoRoot, outFile) + }`, + ); +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +if (import.meta.main) { + await main(); +} diff --git a/test/ui-server.smoke.ts b/test/ui-server.smoke.ts new file mode 100644 index 0000000..4563598 --- /dev/null +++ b/test/ui-server.smoke.ts @@ -0,0 +1,67 @@ +// Smoke test for the UI server: bundle mode serves /index.html, +// disk mode serves /app.js, /_ui/overview returns structured JSON. +// Run with: deno run --allow-all test/ui-server.smoke.ts + +import { UiServer } from '../ts/ui/server.ts'; +import { ContainerManager } from '../ts/containers/container-manager.ts'; +import { ClusterManager } from '../ts/cluster/cluster-manager.ts'; + +async function probe(source: 'bundle' | 'disk', port: number): Promise { + const cm = new ContainerManager(); + const cluster = new ClusterManager(); + cluster.configure({ + enabled: false, + nodeName: 'test-node', + role: 'standalone', + bindHost: '127.0.0.1', + gossipPort: 7946, + heartbeatIntervalMs: 5000, + seedNodes: [], + }); + + const server = new UiServer( + { enabled: true, port, host: '127.0.0.1', assetSource: source }, + cm, + cluster, + ); + await server.start(); + + try { + const index = await fetch(`http://127.0.0.1:${port}/`); + const indexBody = await index.text(); + if (!index.ok || !indexBody.includes('ModelGrid')) { + throw new Error(`[${source}] index.html missing expected content (status=${index.status})`); + } + + const app = await fetch(`http://127.0.0.1:${port}/app.js`); + const appBody = await app.text(); + if (!app.ok || !appBody.includes('ModelGrid UI')) { + throw new Error(`[${source}] app.js missing expected content (status=${app.status})`); + } + + const spa = await fetch(`http://127.0.0.1:${port}/cluster/nodes`); + const spaBody = await spa.text(); + if (!spa.ok || !spaBody.includes('ModelGrid')) { + throw new Error(`[${source}] SPA fallback did not return index.html (status=${spa.status})`); + } + + const overview = await fetch(`http://127.0.0.1:${port}/_ui/overview`); + const data = await overview.json(); + if (!overview.ok || data.node?.name !== 'test-node' || !data.health?.status) { + throw new Error(`[${source}] /_ui/overview unexpected: ${JSON.stringify(data)}`); + } + + const missing = await fetch(`http://127.0.0.1:${port}/nope.png`); + if (missing.status !== 404) { + throw new Error(`[${source}] expected 404 for missing asset, got ${missing.status}`); + } + + console.log(`ok: ${source} mode — index, app.js, SPA fallback, /_ui/overview, 404`); + } finally { + await server.stop(); + } +} + +await probe('bundle', 18081); +await probe('disk', 18082); +console.log('UI server smoke test passed'); diff --git a/ts/cli/config-handler.ts b/ts/cli/config-handler.ts index b8f780e..dea4baa 100644 --- a/ts/cli/config-handler.ts +++ b/ts/cli/config-handler.ts @@ -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', diff --git a/ts/daemon.ts b/ts/daemon.ts index ed59613..2f32e6b 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -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 { + 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 */ diff --git a/ts/interfaces/config.ts b/ts/interfaces/config.ts index b107425..77c293e 100644 --- a/ts/interfaces/config.ts +++ b/ts/interfaces/config.ts @@ -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 */ diff --git a/ts/modelgrid.ts b/ts/modelgrid.ts index 8fe8336..f001e30 100644 --- a/ts/modelgrid.ts +++ b/ts/modelgrid.ts @@ -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', diff --git a/ts/ui/index.ts b/ts/ui/index.ts new file mode 100644 index 0000000..751786d --- /dev/null +++ b/ts/ui/index.ts @@ -0,0 +1 @@ +export { UiServer } from './server.ts'; diff --git a/ts/ui/server.ts b/ts/ui/server.ts new file mode 100644 index 0000000..cb52d21 --- /dev/null +++ b/ts/ui/server.ts @@ -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 | 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'; +} diff --git a/ts_web/app.css b/ts_web/app.css new file mode 100644 index 0000000..18bef8c --- /dev/null +++ b/ts_web/app.css @@ -0,0 +1,187 @@ +:root { + color-scheme: dark; + --bg: #000; + --bg-1: #0b0b0d; + --bg-2: #14141a; + --fg: #e6e6ea; + --fg-dim: #8a8a92; + --border: #23232b; + --accent: #4357d9; + --ok: #2ecc71; + --warn: #f1c40f; + --err: #e74c3c; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; +} + +body { + display: grid; + grid-template-columns: 220px 1fr; +} + +a { color: inherit; text-decoration: none; } + +.dim { color: var(--fg-dim); } + +.nav { + background: var(--bg-1); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100vh; + position: sticky; + top: 0; +} + +.nav-brand { + padding: 20px 16px 12px; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; + border-bottom: 1px solid var(--border); +} + +.nav-items { + display: flex; + flex-direction: column; + padding: 8px 0; + flex: 1; + overflow-y: auto; +} + +.nav-items a { + padding: 8px 16px; + color: var(--fg-dim); + border-left: 2px solid transparent; + transition: color 0.1s, background 0.1s, border-color 0.1s; +} + +.nav-items a:hover { + color: var(--fg); + background: var(--bg-2); +} + +.nav-items a.active { + color: var(--fg); + background: var(--bg-2); + border-left-color: var(--accent); +} + +.nav-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + font-size: 12px; +} + +main { + padding: 24px 32px; + overflow-y: auto; + height: 100vh; +} + +h1 { + font-size: 18px; + font-weight: 600; + margin: 0 0 20px; + letter-spacing: 0.01em; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.card { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; +} + +.card-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); + margin-bottom: 8px; +} + +.card-value { + font-size: 22px; + font-weight: 600; +} + +.card-sub { + font-size: 12px; + color: var(--fg-dim); + margin-top: 4px; +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} +.status-dot.ok { background: var(--ok); } +.status-dot.warn{ background: var(--warn); } +.status-dot.err { background: var(--err); } + +table { + width: 100%; + border-collapse: collapse; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +th, td { + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + font-weight: normal; +} + +th { + color: var(--fg-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + background: var(--bg-2); +} + +tr:last-child td { border-bottom: none; } + +.placeholder { + padding: 40px; + text-align: center; + color: var(--fg-dim); + background: var(--bg-1); + border: 1px dashed var(--border); + border-radius: 6px; +} + +.error { + background: var(--bg-1); + border: 1px solid var(--err); + color: var(--err); + padding: 12px 16px; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} diff --git a/ts_web/app.js b/ts_web/app.js new file mode 100644 index 0000000..3b06bef --- /dev/null +++ b/ts_web/app.js @@ -0,0 +1,161 @@ +// ModelGrid UI — vanilla client. Bundled into ts_bundled/bundle.ts for +// the single-binary build, or served from disk in dev mode. + +const VIEWS = [ + 'overview', + 'cluster', + 'gpus', + 'deployments', + 'models', + 'access', + 'logs', + 'metrics', + 'settings', +]; + +const view = document.getElementById('view'); +const nodeIdent = document.getElementById('node-ident'); +const nodeVersion = document.getElementById('node-version'); + +function parseHash() { + const raw = location.hash.replace(/^#\/?/, ''); + const [top = 'overview'] = raw.split('/').filter(Boolean); + return VIEWS.includes(top) ? top : 'overview'; +} + +function setActive(current) { + document.querySelectorAll('.nav-items a').forEach((el) => { + el.classList.toggle('active', el.dataset.view === current); + }); +} + +async function fetchHealth() { + const res = await fetch('/_ui/overview', { headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +function statusDot(status) { + const ok = status === 'ok'; + const warn = status === 'degraded'; + const cls = ok ? 'ok' : warn ? 'warn' : 'err'; + return ``; +} + +async function renderOverview() { + view.innerHTML = `

Overview

Loading…
`; + try { + const data = await fetchHealth(); + const health = data.health; + const containers = health.containers || 0; + const models = health.models || 0; + const gpus = health.gpus || 0; + const uptime = health.uptime || 0; + const detailEntries = Object.entries(health.details?.containers || {}); + const runningContainers = detailEntries.filter(([, v]) => v === 'healthy').length; + + view.innerHTML = ` +

Overview

+
+
+
Fleet
+
${statusDot(health.status)}${health.status}
+
v${health.version} · up ${formatUptime(uptime)}
+
+
+
Deployments
+
${runningContainers} / ${containers}
+
${containers === 0 ? 'no deployments' : `${runningContainers} healthy`}
+
+
+
GPUs
+
${gpus}
+
${gpus === 0 ? 'no GPU detected' : 'detected'}
+
+
+
Models
+
${models}
+
served via OpenAI API
+
+
+

Deployments

+ ${renderContainerTable(detailEntries)} + `; + if (data.node) { + nodeIdent.textContent = `${data.node.name} · ${data.node.role}`; + nodeVersion.textContent = `v${data.node.version}`; + } + } catch (err) { + view.innerHTML = `

Overview

Failed to load: ${escapeHtml(String(err.message || err))}
`; + } +} + +function renderContainerTable(entries) { + if (entries.length === 0) { + return `
No deployments configured. Add one with modelgrid run <model>.
`; + } + const rows = entries.map(([id, state]) => ` + + ${escapeHtml(id)} + ${statusDot(state === 'healthy' ? 'ok' : 'err')}${escapeHtml(state)} + + `).join(''); + return `${rows}
ContainerHealth
`; +} + +function renderPlaceholder(name) { + view.innerHTML = ` +

${name}

+
+ This view is part of the UI concept (see readme.ui.md) but is not implemented yet. + Use the CLI for now: modelgrid ${cliHint(name)}. +
+ `; +} + +function cliHint(view) { + const map = { + Cluster: 'cluster status', + GPUs: 'gpu list', + Deployments: 'ps', + Models: 'model list', + Access: 'config apikey list', + Logs: 'service logs', + Metrics: 'service status', + Settings: 'config show', + }; + return map[view] || '--help'; +} + +function formatUptime(s) { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +function escapeHtml(s) { + return s.replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function route() { + const current = parseHash(); + setActive(current); + switch (current) { + case 'overview': return renderOverview(); + case 'cluster': return renderPlaceholder('Cluster'); + case 'gpus': return renderPlaceholder('GPUs'); + case 'deployments': return renderPlaceholder('Deployments'); + case 'models': return renderPlaceholder('Models'); + case 'access': return renderPlaceholder('Access'); + case 'logs': return renderPlaceholder('Logs'); + case 'metrics': return renderPlaceholder('Metrics'); + case 'settings': return renderPlaceholder('Settings'); + } +} + +window.addEventListener('hashchange', route); +if (!location.hash) location.hash = '#/overview'; +route(); diff --git a/ts_web/index.html b/ts_web/index.html new file mode 100644 index 0000000..8129894 --- /dev/null +++ b/ts_web/index.html @@ -0,0 +1,32 @@ + + + + + + + ModelGrid + + + + +
+ + +