Skip to content

Commit 58e261f

Browse files
committed
Add new vmm ui
1 parent b7969e1 commit 58e261f

24 files changed

Lines changed: 6882 additions & 1 deletion

vmm/rpc/proto/prpc.proto

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
syntax = "proto3";
6+
7+
package prpc;
8+
9+
/// RPC error payload returned by prpc endpoints.
10+
message PrpcError {
11+
string message = 1;
12+
}
13+

vmm/src/main_routes.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ async fn index(app: &State<App>) -> (ContentType, String) {
3939
(ContentType::HTML, html)
4040
}
4141

42+
#[get("/beta")]
43+
async fn beta() -> (ContentType, String) {
44+
(ContentType::HTML, file_or_include_str!("console_beta.html"))
45+
}
46+
4247
#[get("/res/<path>")]
4348
async fn res(path: &str) -> Result<(ContentType, String), Custom<String>> {
4449
match path {
@@ -160,5 +165,5 @@ fn vm_logs(
160165
}
161166

162167
pub fn routes() -> Vec<Route> {
163-
routes![index, res, vm_logs]
168+
routes![index, beta, res, vm_logs]
164169
}

vmm/ui/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
dist
3+
build
4+
*.log

vmm/ui/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# dstack Console UI
2+
3+
This directory contains the source for the Vue-based VM management console.
4+
5+
## Usage
6+
7+
```bash
8+
# Install dev dependencies (installs protobufjs CLI)
9+
npm install
10+
11+
# Build the beta console once
12+
npm run build
13+
14+
# Build continuously (writes beta console on changes)
15+
npm run watch
16+
```
17+
18+
The build step generates a single-file HTML artifact at `../src/console_beta.html`
19+
which is served by `dstack-vmm` under the `/beta` path. The existing
20+
`console.html` remains untouched so both versions can coexist.
21+
22+
The UI codebase is written in TypeScript. The build pipeline performs three steps:
23+
24+
1. `scripts/build_proto.sh` (borrowed from `phala-blockchain`) uses `pbjs/pbts` to regenerate static JS bindings for `vmm_rpc.proto`.
25+
2. `tsc` transpiles `src/**/*.ts` into `build/ts/`.
26+
3. `build.mjs` bundles the transpiled output together with the runtime assets into a single HTML page `console_beta.html`.

vmm/ui/build.mjs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import fs from 'fs/promises';
5+
import path from 'path';
6+
import { spawn } from 'child_process';
7+
import { createRequire } from 'module';
8+
9+
const ROOT = path.resolve(new URL('.', import.meta.url).pathname);
10+
const SOURCE_DIR = path.join(ROOT, 'src');
11+
const TS_OUT_DIR = path.join(ROOT, 'build', 'ts');
12+
const DIST_DIR = path.join(ROOT, 'dist');
13+
const ENTRY = 'main.js';
14+
const PBJS = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'pbjs.cmd' : 'pbjs');
15+
const PBTS = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'pbts.cmd' : 'pbts');
16+
const TSC = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc');
17+
let MODULE_DIR = TS_OUT_DIR;
18+
const nodeRequire = createRequire(path.join(ROOT, 'package.json'));
19+
20+
function canonicalId(absPath) {
21+
return path.relative(ROOT, absPath).split(path.sep).join('/');
22+
}
23+
24+
function resolveModule(parentId, request) {
25+
const base = parentId ? path.dirname(path.resolve(ROOT, parentId)) : MODULE_DIR;
26+
const absPath = nodeRequire.resolve(request, { paths: [base] });
27+
return canonicalId(absPath);
28+
}
29+
30+
async function readFileCached(filePath) {
31+
return fs.readFile(filePath, 'utf-8');
32+
}
33+
34+
async function collectModules(entryId) {
35+
const modules = new Map();
36+
37+
async function processModule(moduleId) {
38+
if (modules.has(moduleId)) {
39+
return;
40+
}
41+
const absPath = path.resolve(ROOT, moduleId);
42+
const ext = path.extname(absPath);
43+
if (ext === '.html') {
44+
const content = await readFileCached(absPath);
45+
modules.set(moduleId, {
46+
type: 'raw',
47+
code: `module.exports = ${JSON.stringify(content)};`,
48+
dependencyMap: {},
49+
});
50+
return;
51+
}
52+
if (ext === '.json') {
53+
const content = await readFileCached(absPath);
54+
modules.set(moduleId, {
55+
type: 'raw',
56+
code: `module.exports = ${content};`,
57+
dependencyMap: {},
58+
});
59+
return;
60+
}
61+
if (ext !== '.js' && ext !== '.cjs' && ext !== '.mjs') {
62+
throw new Error(`Unsupported module extension: ${absPath}`);
63+
}
64+
const source = await readFileCached(absPath);
65+
const dependencyMap = {};
66+
const requireRegex = /require\(['"](.+?)['"]\)/g;
67+
let match;
68+
while ((match = requireRegex.exec(source)) !== null) {
69+
const lineStart = source.lastIndexOf('\n', match.index) + 1;
70+
const trimmed = source.slice(lineStart, match.index).trim();
71+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) {
72+
continue;
73+
}
74+
const request = match[1];
75+
const resolved = resolveModule(moduleId, request);
76+
dependencyMap[request] = resolved;
77+
}
78+
const dependencies = Array.from(new Set(Object.values(dependencyMap)));
79+
modules.set(moduleId, {
80+
type: 'js',
81+
code: source,
82+
dependencyMap,
83+
dependencies,
84+
});
85+
for (const dep of dependencies) {
86+
await processModule(dep);
87+
}
88+
}
89+
90+
await processModule(entryId);
91+
return modules;
92+
}
93+
94+
function createBundle(modules, entryId) {
95+
const moduleEntries = [];
96+
for (const [id, info] of modules.entries()) {
97+
const deps = JSON.stringify(info.dependencyMap || {});
98+
moduleEntries.push(
99+
`'${id}': { factory: function(module, exports, require) {\n${info.code}\n}, map: ${deps} }`,
100+
);
101+
}
102+
return `(function(){\n const modules = {\n${moduleEntries.join(',\n')}\n };\n const cache = {};\n function load(id) {\n if (cache[id]) {\n return cache[id];\n }\n const entry = modules[id];\n if (!entry) {\n throw new Error('Unknown module ' + id);\n }\n const module = { exports: {} };\n cache[id] = module.exports;\n entry.factory(module, module.exports, createRequire(id));\n cache[id] = module.exports;\n return cache[id];\n }\n function createRequire(parentId) {\n return function(request) {\n const parent = modules[parentId];\n if (!parent) {\n throw new Error('Unknown parent module ' + parentId);\n }\n const resolved = parent.map && parent.map[request];\n if (!resolved) {\n throw new Error('Cannot resolve module ' + request + ' from ' + parentId);\n }\n return load(resolved);\n };\n }\n load('${entryId}');\n})();`;
103+
}
104+
105+
async function inlineStyles(html, baseDir) {
106+
const linkRegex = /<link\s+rel=["']stylesheet["']\s+href=["'](.+?)["']\s*\/?>(?:\s*<\/link>)?/gi;
107+
let result = html;
108+
let match;
109+
while ((match = linkRegex.exec(html)) !== null) {
110+
const href = match[1];
111+
const cssPath = path.resolve(baseDir, href);
112+
const cssContent = await fs.readFile(cssPath, 'utf-8');
113+
const styleTag = `<style>\n${cssContent}\n</style>`;
114+
result = result.replace(match[0], styleTag);
115+
}
116+
return result;
117+
}
118+
119+
async function inlineScripts(html, scripts) {
120+
let result = html;
121+
for (const { placeholder, code } of scripts) {
122+
result = result.replace(placeholder, `<script>\n${code}\n</script>`);
123+
}
124+
return result;
125+
}
126+
127+
async function run(command, args) {
128+
await new Promise((resolve, reject) => {
129+
const proc = spawn(command, args, { stdio: 'inherit' });
130+
proc.on('close', (code) => {
131+
if (code === 0) {
132+
resolve();
133+
} else {
134+
reject(new Error(`${command} exited with code ${code}`));
135+
}
136+
});
137+
});
138+
}
139+
140+
async function copyDir(src, dest) {
141+
const entries = await fs.readdir(src, { withFileTypes: true });
142+
await fs.mkdir(dest, { recursive: true });
143+
await Promise.all(
144+
entries.map((entry) => {
145+
const srcPath = path.join(src, entry.name);
146+
const destPath = path.join(dest, entry.name);
147+
if (entry.isDirectory()) {
148+
return copyDir(srcPath, destPath);
149+
}
150+
return fs.copyFile(srcPath, destPath);
151+
}),
152+
);
153+
}
154+
155+
async function compileProto() {
156+
await run('bash', [path.join(ROOT, 'scripts', 'build_proto.sh')]);
157+
}
158+
159+
async function compileTypeScript() {
160+
await fs.rm(TS_OUT_DIR, { recursive: true, force: true });
161+
await run(TSC, ['--project', path.join(ROOT, 'tsconfig.json')]);
162+
await copyDir(path.join(SOURCE_DIR, 'templates'), path.join(TS_OUT_DIR, 'templates'));
163+
}
164+
165+
async function build({ watch = false } = {}) {
166+
await fs.mkdir(DIST_DIR, { recursive: true });
167+
MODULE_DIR = TS_OUT_DIR;
168+
169+
await compileProto();
170+
await compileTypeScript();
171+
172+
const entryId = canonicalId(path.resolve(MODULE_DIR, ENTRY));
173+
const modules = await collectModules(entryId);
174+
const bundle = createBundle(modules, entryId);
175+
176+
const indexPath = path.join(SOURCE_DIR, 'index.html');
177+
let html = await fs.readFile(indexPath, 'utf-8');
178+
html = await inlineStyles(html, SOURCE_DIR);
179+
180+
const vuePlaceholder = /<script\s+src=["']\.\.\/vendor\/vue\.global\.prod\.js["']><\/script>/i;
181+
const vuePath = path.join(ROOT, 'vendor/vue.global.prod.js');
182+
let vueInlined = false;
183+
try {
184+
const vueCode = await fs.readFile(vuePath, 'utf-8');
185+
html = html.replace(vuePlaceholder, `<script>\n${vueCode}\n</script>`);
186+
vueInlined = true;
187+
} catch {
188+
console.warn('Warning: vendor/vue.global.prod.js not found – using CDN fallback.');
189+
}
190+
if (!vueInlined) {
191+
html = html.replace(
192+
vuePlaceholder,
193+
'<script src="https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js"></script>',
194+
);
195+
}
196+
197+
html = await inlineScripts(html, [
198+
{
199+
placeholder: '<script src="./main.js"></script>',
200+
code: bundle,
201+
},
202+
]);
203+
204+
const distFile = path.join(DIST_DIR, 'index.html');
205+
await fs.writeFile(distFile, html);
206+
207+
const targetFile = path.resolve(ROOT, '../src/console_beta.html');
208+
await fs.writeFile(targetFile, html);
209+
210+
if (watch) {
211+
console.log('Watching for changes...');
212+
const watcher = fs.watch(SOURCE_DIR, { recursive: true }, async () => {
213+
try {
214+
await compileProto();
215+
await compileTypeScript();
216+
const mods = await collectModules(entryId);
217+
const rebundle = createBundle(mods, entryId);
218+
let rehtml = await fs.readFile(indexPath, 'utf-8');
219+
rehtml = await inlineStyles(rehtml, SOURCE_DIR);
220+
let vueEmbedded = false;
221+
try {
222+
const vueCode = await fs.readFile(vuePath, 'utf-8');
223+
rehtml = rehtml.replace(vuePlaceholder, `<script>\n${vueCode}\n</script>`);
224+
vueEmbedded = true;
225+
} catch {
226+
console.warn('Warning: vendor/vue.global.prod.js not found – using CDN fallback.');
227+
}
228+
if (!vueEmbedded) {
229+
rehtml = rehtml.replace(
230+
vuePlaceholder,
231+
'<script src="https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js"></script>',
232+
);
233+
}
234+
rehtml = await inlineScripts(rehtml, [
235+
{
236+
placeholder: '<script src="./main.js"></script>',
237+
code: rebundle,
238+
},
239+
]);
240+
await fs.writeFile(distFile, rehtml);
241+
await fs.writeFile(targetFile, rehtml);
242+
console.log('Rebuilt console');
243+
} catch (err) {
244+
console.error('Build failed:', err);
245+
}
246+
});
247+
process.on('SIGINT', () => watcher.close());
248+
}
249+
}
250+
251+
const watchMode = process.argv.includes('--watch');
252+
253+
build({ watch: watchMode }).catch((error) => {
254+
console.error(error);
255+
process.exit(1);
256+
});

0 commit comments

Comments
 (0)