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:
@@ -1,6 +1,9 @@
|
|||||||
# Compiled Deno binaries (built by scripts/compile-all.sh)
|
# Compiled Deno binaries (built by scripts/compile-all.sh)
|
||||||
dist/binaries/
|
dist/binaries/
|
||||||
|
|
||||||
|
# Generated UI bundle (built by scripts/bundle-ui.ts)
|
||||||
|
ts_bundled/
|
||||||
|
|
||||||
# Deno cache and lock file
|
# Deno cache and lock file
|
||||||
.deno/
|
.deno/
|
||||||
deno.lock
|
deno.lock
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"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": "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": "deno test --allow-all test/",
|
||||||
"test:watch": "deno test --allow-all --watch test/",
|
"test:watch": "deno test --allow-all --watch test/",
|
||||||
"check": "deno check mod.ts",
|
"check": "deno check mod.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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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');
|
||||||
@@ -218,6 +218,12 @@ export class ConfigHandler {
|
|||||||
cors: true,
|
cors: true,
|
||||||
corsOrigins: ['*'],
|
corsOrigins: ['*'],
|
||||||
},
|
},
|
||||||
|
ui: {
|
||||||
|
enabled: true,
|
||||||
|
port: 8081,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
assetSource: 'bundle',
|
||||||
|
},
|
||||||
docker: {
|
docker: {
|
||||||
networkName: 'modelgrid',
|
networkName: 'modelgrid',
|
||||||
runtime: 'docker',
|
runtime: 'docker',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { logger } from './logger.ts';
|
|||||||
import { TIMING } from './constants.ts';
|
import { TIMING } from './constants.ts';
|
||||||
import type { ModelGrid } from './modelgrid.ts';
|
import type { ModelGrid } from './modelgrid.ts';
|
||||||
import { ApiServer } from './api/server.ts';
|
import { ApiServer } from './api/server.ts';
|
||||||
|
import { UiServer } from './ui/server.ts';
|
||||||
import type { IModelGridConfig } from './interfaces/config.ts';
|
import type { IModelGridConfig } from './interfaces/config.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,7 @@ export class Daemon {
|
|||||||
private modelgrid: ModelGrid;
|
private modelgrid: ModelGrid;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
private apiServer?: ApiServer;
|
private apiServer?: ApiServer;
|
||||||
|
private uiServer?: UiServer;
|
||||||
|
|
||||||
constructor(modelgrid: ModelGrid) {
|
constructor(modelgrid: ModelGrid) {
|
||||||
this.modelgrid = modelgrid;
|
this.modelgrid = modelgrid;
|
||||||
@@ -48,6 +50,9 @@ export class Daemon {
|
|||||||
// Start API server
|
// Start API server
|
||||||
await this.startApiServer(config);
|
await this.startApiServer(config);
|
||||||
|
|
||||||
|
// Start UI server (runs on its own port, serves the operations console)
|
||||||
|
await this.startUiServer(config);
|
||||||
|
|
||||||
// Start containers
|
// Start containers
|
||||||
await this.startContainers();
|
await this.startContainers();
|
||||||
|
|
||||||
@@ -86,6 +91,11 @@ export class Daemon {
|
|||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
|
// Stop UI server
|
||||||
|
if (this.uiServer) {
|
||||||
|
await this.uiServer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop API server
|
// Stop API server
|
||||||
if (this.apiServer) {
|
if (this.apiServer) {
|
||||||
await this.apiServer.stop();
|
await this.apiServer.stop();
|
||||||
@@ -114,6 +124,26 @@ export class Daemon {
|
|||||||
await this.apiServer.start();
|
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
|
* Start configured containers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ export interface IModelConfig {
|
|||||||
autoLoad: string[];
|
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
|
* Main ModelGrid configuration interface
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +90,8 @@ export interface IModelGridConfig {
|
|||||||
version: string;
|
version: string;
|
||||||
/** API server configuration */
|
/** API server configuration */
|
||||||
api: IApiConfig;
|
api: IApiConfig;
|
||||||
|
/** UI server configuration */
|
||||||
|
ui: IUiConfig;
|
||||||
/** Docker configuration */
|
/** Docker configuration */
|
||||||
docker: IDockerConfig;
|
docker: IDockerConfig;
|
||||||
/** GPU configuration */
|
/** GPU configuration */
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export class ModelGrid {
|
|||||||
cors: config.api?.cors ?? true,
|
cors: config.api?.cors ?? true,
|
||||||
corsOrigins: config.api?.corsOrigins || ['*'],
|
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: {
|
docker: {
|
||||||
networkName: config.docker?.networkName || 'modelgrid',
|
networkName: config.docker?.networkName || 'modelgrid',
|
||||||
runtime: config.docker?.runtime || 'docker',
|
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';
|
||||||
|
}
|
||||||
+187
@@ -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;
|
||||||
|
}
|
||||||
+161
@@ -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 `<span class="status-dot ${cls}"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOverview() {
|
||||||
|
view.innerHTML = `<h1>Overview</h1><div id="ovstate" class="placeholder">Loading…</div>`;
|
||||||
|
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 = `
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Fleet</div>
|
||||||
|
<div class="card-value">${statusDot(health.status)}${health.status}</div>
|
||||||
|
<div class="card-sub">v${health.version} · up ${formatUptime(uptime)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Deployments</div>
|
||||||
|
<div class="card-value">${runningContainers} / ${containers}</div>
|
||||||
|
<div class="card-sub">${containers === 0 ? 'no deployments' : `${runningContainers} healthy`}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">GPUs</div>
|
||||||
|
<div class="card-value">${gpus}</div>
|
||||||
|
<div class="card-sub">${gpus === 0 ? 'no GPU detected' : 'detected'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Models</div>
|
||||||
|
<div class="card-value">${models}</div>
|
||||||
|
<div class="card-sub">served via OpenAI API</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin-top:24px">Deployments</h1>
|
||||||
|
${renderContainerTable(detailEntries)}
|
||||||
|
`;
|
||||||
|
if (data.node) {
|
||||||
|
nodeIdent.textContent = `${data.node.name} · ${data.node.role}`;
|
||||||
|
nodeVersion.textContent = `v${data.node.version}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
view.innerHTML = `<h1>Overview</h1><div class="error">Failed to load: ${escapeHtml(String(err.message || err))}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContainerTable(entries) {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return `<div class="placeholder">No deployments configured. Add one with <code>modelgrid run <model></code>.</div>`;
|
||||||
|
}
|
||||||
|
const rows = entries.map(([id, state]) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(id)}</td>
|
||||||
|
<td>${statusDot(state === 'healthy' ? 'ok' : 'err')}${escapeHtml(state)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
return `<table><thead><tr><th>Container</th><th>Health</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaceholder(name) {
|
||||||
|
view.innerHTML = `
|
||||||
|
<h1>${name}</h1>
|
||||||
|
<div class="placeholder">
|
||||||
|
This view is part of the UI concept (see <code>readme.ui.md</code>) but is not implemented yet.
|
||||||
|
Use the CLI for now: <code>modelgrid ${cliHint(name)}</code>.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<title>ModelGrid</title>
|
||||||
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="nav">
|
||||||
|
<div class="nav-brand">ModelGrid</div>
|
||||||
|
<nav class="nav-items">
|
||||||
|
<a href="#/overview" data-view="overview">Overview</a>
|
||||||
|
<a href="#/cluster" data-view="cluster">Cluster</a>
|
||||||
|
<a href="#/gpus" data-view="gpus">GPUs</a>
|
||||||
|
<a href="#/deployments" data-view="deployments">Deployments</a>
|
||||||
|
<a href="#/models" data-view="models">Models</a>
|
||||||
|
<a href="#/access" data-view="access">Access</a>
|
||||||
|
<a href="#/logs" data-view="logs">Logs</a>
|
||||||
|
<a href="#/metrics" data-view="metrics">Metrics</a>
|
||||||
|
<a href="#/settings" data-view="settings">Settings</a>
|
||||||
|
</nav>
|
||||||
|
<div class="nav-footer">
|
||||||
|
<div id="node-ident">—</div>
|
||||||
|
<div id="node-version" class="dim">—</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main id="view"></main>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user