Ubuntu GPU sessions: spawn/destroy full desktop with 4080 attached via noVNC proxy. Fixed branding (was 'My Google AI Studio App').

This commit is contained in:
drjones
2026-08-24 21:33:40 -07:00
parent d0f81b7432
commit 214f97b580
5 changed files with 334 additions and 7 deletions

View File

@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title> <title>VortexGPU — Rent a GPU PC by the Hour</title>
<meta name="description" content="An application built with Google AI Studio." /> <meta name="description" content="Rent a real GPU PC by the hour. Ubuntu sessions, Windows RDP, Linux SSH — Bitcoin via BTCPay." />
<meta property="og:title" content="My Google AI Studio App" /> <meta property="og:title" content="VortexGPU — Rent a GPU PC by the Hour" />
<meta property="og:description" content="An application built with Google AI Studio." /> <meta property="og:description" content="Rent a real GPU PC by the hour. Ubuntu sessions, Windows RDP, Linux SSH." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
</head> </head>

173
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
@@ -1554,6 +1555,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -1814,6 +1824,18 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2171,6 +2193,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -2284,6 +2312,18 @@
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -2317,6 +2357,26 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -2573,6 +2633,37 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz",
"integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.18.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -2613,6 +2704,45 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -3006,6 +3136,31 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": { "node_modules/mime": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -3371,6 +3526,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/retry": { "node_modules/retry": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -3657,6 +3818,18 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -17,6 +17,7 @@
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",

View File

@@ -7,6 +7,7 @@ import { execFile } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { createServer as createViteServer } from "vite"; import { createServer as createViteServer } from "vite";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { createProxyMiddleware } from "http-proxy-middleware";
const exec = promisify(execFile); const exec = promisify(execFile);
@@ -68,7 +69,7 @@ type GpuNode = {
type GpuJob = { type GpuJob = {
id: string; id: string;
hostname: string; hostname: string;
kind: "shell" | "hashcat" | "comfyui"; kind: "shell" | "hashcat" | "comfyui" | "provision_ubuntu" | "destroy_ubuntu";
command: string; command: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
status: "pending" | "running" | "done" | "failed"; status: "pending" | "running" | "done" | "failed";
@@ -119,6 +120,13 @@ db.exec(`
btcpay_invoice_id TEXT, checkout_link TEXT, btcpay_invoice_id TEXT, checkout_link TEXT,
status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, settled_at INTEGER status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, settled_at INTEGER
); );
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
instance_id TEXT NOT NULL UNIQUE, node_hostname TEXT NOT NULL,
node_ip TEXT NOT NULL, port INTEGER NOT NULL, password TEXT NOT NULL,
resolution TEXT, state TEXT NOT NULL DEFAULT 'provisioning',
created_at INTEGER NOT NULL
);
`); `);
// ---- Migrations (add columns that predate password/unlimited) ---- // ---- Migrations (add columns that predate password/unlimited) ----
@@ -202,6 +210,21 @@ function allocatePort(): number {
return p; return p;
} }
// Allocate a session (noVNC) port from a dedicated range, distinct from VM ports.
function allocateSessionPort(): number {
const used = all<{ port: number }>("SELECT port FROM sessions WHERE port IS NOT NULL").map((r) => r.port);
let p = 6090;
while (used.includes(p) && p < 6190) p++;
return p;
}
// Enqueue a GPU job for a node to pick up on its next poll.
function dispatchJob(hostname: string, kind: GpuJob["kind"], command: string, payload: Record<string, unknown>): GpuJob {
const job: GpuJob = { id: "job_" + crypto.randomBytes(6).toString("hex"), hostname, kind, command, payload, status: "pending", result: "", createdAt: Date.now(), completedAt: null };
jobs.push(job); persistJobs();
return job;
}
// ---- auth helpers ---- // ---- auth helpers ----
function nodeAuthorized(req: express.Request) { return req.headers["x-node-secret"] === NODE_SECRET; } function nodeAuthorized(req: express.Request) { return req.headers["x-node-secret"] === NODE_SECRET; }
function adminAuthorized(req: express.Request) { return (req.headers["authorization"] || "") === `Bearer ${ADMIN_TOKEN}`; } function adminAuthorized(req: express.Request) { return (req.headers["authorization"] || "") === `Bearer ${ADMIN_TOKEN}`; }
@@ -380,6 +403,46 @@ async function startServer() {
res.json({ received: true }); res.json({ received: true });
}); });
// ===== UBUNTU GPU SESSIONS (spawn in-browser desktop with the 4080 attached) =====
app.post("/api/session/spawn", (req, res) => {
const { userId, resolution } = req.body || {};
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
if (!user) return res.status(400).json({ error: "invalid user" });
// Target the Linux GPU node (nightmare) that runs the Ubuntu-session agent.
const hostname = "nightmare";
const node = nodes[hostname];
if (!node || Date.now() - node.lastSeen > 30_000) {
return res.status(503).json({ error: "GPU node offline — try again shortly" });
}
const instanceId = "sess_" + crypto.randomBytes(4).toString("hex");
const port = allocateSessionPort();
const password = "Ub" + crypto.randomBytes(6).toString("hex") + "!";
const reso = str(resolution, "1440x900");
const id = "ses_" + crypto.randomBytes(8).toString("hex");
q("INSERT INTO sessions (id,user_id,instance_id,node_hostname,node_ip,port,password,resolution,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
id, user.id, instanceId, hostname, node.ip, port, password, reso, "provisioning", Date.now());
dispatchJob(hostname, "provision_ubuntu", "", { instanceId, port, password, resolution: reso });
res.json({ id, instanceId, port, password, resolution: reso, state: "provisioning", url: `/session/${instanceId}/` });
});
app.get("/api/sessions", (req, res) => {
const userId = str(req.query.userId, "");
if (!userId) return res.status(400).json({ error: "userId required" });
res.json(all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", userId));
});
app.post("/api/session/destroy", (req, res) => {
const sess = one<any>("SELECT * FROM sessions WHERE id=? AND user_id=?", str(req.body?.sessionId, ""), str(req.body?.userId, ""));
if (!sess) return res.status(404).json({ error: "not found" });
q("UPDATE sessions SET state='stopping' WHERE id=?", sess.id);
dispatchJob(sess.node_hostname, "destroy_ubuntu", "", { instanceId: sess.instance_id });
res.json({ ok: true });
});
// ===== GPU NODE LAYER ===== // ===== GPU NODE LAYER =====
app.post("/api/node/register", (req, res) => { app.post("/api/node/register", (req, res) => {
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" }); if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
@@ -417,6 +480,13 @@ async function startServer() {
job.status = req.body?.ok ? "done" : "failed"; job.status = req.body?.ok ? "done" : "failed";
job.result = String(req.body?.result ?? ""); job.result = String(req.body?.result ?? "");
job.completedAt = Date.now(); job.completedAt = Date.now();
// Reflect provisioning result onto the session row.
const p = job.payload as any;
if (job.kind === "provision_ubuntu" && p?.instanceId) {
q("UPDATE sessions SET state=? WHERE instance_id=?", req.body?.ok ? "running" : "failed", p.instanceId);
} else if (job.kind === "destroy_ubuntu" && p?.instanceId) {
q("UPDATE sessions SET state='stopped' WHERE instance_id=?", p.instanceId);
}
persistJobs(); persistJobs();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -475,6 +545,22 @@ async function startServer() {
} catch (e) { console.error("[billing]", e); } } catch (e) { console.error("[billing]", e); }
}, 60_000); }, 60_000);
// ===== SESSION noVNC PROXY (WebSocket-capable) =====
const sessionProxy = createProxyMiddleware({
target: "http://127.0.0.1:1",
changeOrigin: true,
ws: true,
pathFilter: "/session/**",
router: (req) => {
const m = (req.url || "").match(/^\/session\/([^/]+)/);
if (!m) return "http://127.0.0.1:1";
const sess = one<any>("SELECT * FROM sessions WHERE instance_id=?", m[1]);
return sess ? `http://${sess.node_ip}:${sess.port}` : "http://127.0.0.1:1";
},
pathRewrite: (path) => path.replace(/^\/session\/[^/]+/, "") || "/",
});
app.use(sessionProxy);
// ===== STATIC ===== // ===== STATIC =====
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" }); const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" });
@@ -488,11 +574,12 @@ async function startServer() {
}); });
} }
app.listen(PORT, "0.0.0.0", () => { const server = app.listen(PORT, "0.0.0.0", () => {
console.log(`[VortexGPU] rent-a-PC gateway on :${PORT}`); console.log(`[VortexGPU] rent-a-PC gateway on :${PORT}`);
console.log(`[VortexGPU] Proxmox ${PVE_HOST} | win tpl ${PVE_TEMPLATE_WIN} | linux tpl ${PVE_TEMPLATE_LINUX}`); console.log(`[VortexGPU] Proxmox ${PVE_HOST} | win tpl ${PVE_TEMPLATE_WIN} | linux tpl ${PVE_TEMPLATE_LINUX}`);
console.log(`[VortexGPU] GPU SKU: ${GPU_SKU}`); console.log(`[VortexGPU] GPU SKU: ${GPU_SKU}`);
}); });
server.on("upgrade", sessionProxy.upgrade);
} }
startServer().catch((e) => { console.error(e); process.exit(1); }); startServer().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -24,12 +24,24 @@ interface ApiVm {
created_at: number; created_at: number;
} }
interface ApiSession {
id: string;
instance_id: string;
node_hostname: string;
port: number;
password: string;
resolution: string;
state: string;
created_at: number;
}
interface Session { id: string; username: string; balance_minutes: number; unlimited?: boolean; } interface Session { id: string; username: string; balance_minutes: number; unlimited?: boolean; }
export default function App() { export default function App() {
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [vms, setVms] = useState<ApiVm[]>([]); const [vms, setVms] = useState<ApiVm[]>([]);
const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 5090 32GB'); const [sessions, setSessions] = useState<ApiSession[]>([]);
const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 4080 SUPER 16GB');
const [maxVms, setMaxVms] = useState(3); const [maxVms, setMaxVms] = useState(3);
const [loginName, setLoginName] = useState(''); const [loginName, setLoginName] = useState('');
const [loginPass, setLoginPass] = useState(''); const [loginPass, setLoginPass] = useState('');
@@ -50,6 +62,8 @@ export default function App() {
setGpuSku(d.gpu_sku || gpuSku); setGpuSku(d.gpu_sku || gpuSku);
setMaxVms(d.max_vms || 3); setMaxVms(d.max_vms || 3);
} }
const sr = await fetch(`/api/sessions?userId=${session.id}`);
if (sr.ok) setSessions(await sr.json());
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}, [session, gpuSku]); }, [session, gpuSku]);
@@ -93,6 +107,22 @@ export default function App() {
refresh(); refresh();
}; };
const handleSpawnSession = async () => {
if (!session) return;
setLoading(true); setError('');
try {
const r = await fetch('/api/session/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id, resolution: '1440x900' }) });
const d = await r.json();
if (r.ok) refresh();
else setError(d.error || 'spawn failed');
} catch { setError('network error'); } finally { setLoading(false); }
};
const handleDestroySession = async (sessionId: string) => {
await fetch('/api/session/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, sessionId }) });
refresh();
};
if (!session) { if (!session) {
return ( return (
<div className="min-h-screen bg-[#05070d] flex items-center justify-center p-6 font-sans"> <div className="min-h-screen bg-[#05070d] flex items-center justify-center p-6 font-sans">
@@ -184,6 +214,42 @@ export default function App() {
</button> </button>
</div> </div>
{/* Ubuntu GPU Sessions */}
<div className="bg-zinc-900/40 border border-emerald-800/40 rounded-2xl p-5">
<div className="flex items-center justify-between mb-2">
<h2 className="font-bold text-lg flex items-center gap-2"><Terminal className="w-5 h-5 text-emerald-400" /> Ubuntu GPU Sessions</h2>
<span className="text-xs text-zinc-500">RTX 4080 SUPER 16GB · in-browser desktop</span>
</div>
<p className="text-xs text-zinc-500 mb-4">Spawn a full Ubuntu desktop in your browser with the 4080 attached (noVNC). Install anything, run any GPU job.</p>
<button onClick={handleSpawnSession} disabled={loading} className="w-full py-3 bg-gradient-to-r from-emerald-500 to-teal-600 text-black font-bold rounded-xl text-sm disabled:opacity-40">
{loading ? 'Spawning...' : '⚡ Spawn Ubuntu Session'}
</button>
{sessions.length > 0 && (
<div className="mt-4 grid grid-cols-1 gap-3">
{sessions.map((s) => (
<div key={s.id} className="bg-black/40 border border-zinc-800 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-400" />
<div>
<div className="font-bold text-sm">{s.instance_id}</div>
<div className="text-[10px] text-zinc-500">{s.resolution} · node {s.node_hostname}:{s.port}</div>
</div>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${s.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : s.state === 'provisioning' ? 'bg-amber-500/20 text-amber-300' : s.state === 'failed' ? 'bg-red-500/20 text-red-300' : 'bg-zinc-800 text-zinc-400'}`}>{s.state}</span>
</div>
<div className="flex gap-2 mt-3">
<a href={`/session/${s.instance_id}/`} target="_blank" rel="noopener noreferrer" className="flex-1 text-center py-2 bg-emerald-500 text-black font-bold rounded-lg text-xs"><ExternalLink className="w-3.5 h-3.5 inline" /> Open Desktop</a>
<button onClick={() => handleDestroySession(s.id)} className="px-3 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10">Stop</button>
</div>
<div className="mt-2 text-[10px] text-zinc-500 font-mono">VNC password: <span className="text-amber-300">{s.password}</span></div>
</div>
))}
</div>
)}
</div>
{/* Machines */} {/* Machines */}
{vms.length === 0 ? ( {vms.length === 0 ? (
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-2xl"> <div className="text-center py-16 border border-dashed border-zinc-800 rounded-2xl">