Initial commit: VortexGPU rent-a-GPU platform + Linux node agent (nightmare 4080 SUPER)
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# VortexGPU — rent-a-PC platform
|
||||
|
||||
# Gateway port
|
||||
PORT=3000
|
||||
|
||||
# Hidden admin (openssl rand -hex 24)
|
||||
ADMIN_TOKEN="CHANGE_ME"
|
||||
|
||||
# Windows GPU node agent secret
|
||||
NODE_SECRET="CHANGE_ME"
|
||||
|
||||
# Proxmox hypervisor
|
||||
PVE_HOST=10.30.20.85
|
||||
PVE_USER=root
|
||||
PVE_TEMPLATE_WIN=504 # comandoVM (Windows 10, template:1)
|
||||
PVE_TEMPLATE_LINUX=990 # vortex-linux-tpl (debian cloud-init)
|
||||
PVE_VMID_START=2000
|
||||
|
||||
# BTCPay
|
||||
BTCPAY_URL=https://10.30.20.140
|
||||
BTCPAY_API_KEY=4367faaccfa84c9a58d8fa65190290b94e385799
|
||||
BTCPAY_STORE_ID=B3nkkrKECSugCBTWEXrm9PtTHNsVzP9WxESryzRdrF5T
|
||||
BTCPAY_PUBLIC=https://btcpay.thetempleofdoom.com
|
||||
BTCPAY_WEBHOOK_SECRET=vortexgpu-webhook-secret-2026
|
||||
|
||||
# Marketing tier label shown to tenants (decoupled from real hardware)
|
||||
GPU_SKU=NVIDIA GeForce RTX 5090 32GB
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
*.pid
|
||||
.DS_Store
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# VortexGPU — Anonymous GPU Rental Platform (Production)
|
||||
|
||||
No-KYC, BTC-paid, browser-access GPU machines. Each tenant provisions a
|
||||
**completely isolated ComfyUI instance** on a shared physical GPU — they see a
|
||||
clean private machine and never know the GPU is time-shared.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
Tenant browser ────► │ VortexGPU Gateway (CT 731, .127:3000) │
|
||||
│ Express + Vite SPA + SQLite │
|
||||
│ • public SPA (login/provision/instances) │
|
||||
│ • real BTCPay invoices + webhook settle │
|
||||
│ • hidden admin /admin?token= │
|
||||
└───────────────┬──────────────────────────┘
|
||||
│ LAN (poll, X-NODE-SECRET)
|
||||
┌─────────────────────────┴─────────────────────────┐
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ shadow-death (.128) │ │ GamingPC (.186) │
|
||||
│ RTX 4080 SUPER 16GB │ │ RTX 3070 │
|
||||
│ vortex-node-agent.ps1 │ │ vortex-node-agent.ps1 │
|
||||
│ → telemetry │ │ → telemetry │
|
||||
│ → provision_comfyui │ │ → provision_comfyui │
|
||||
│ (isolated port+dir) │ │ (isolated port+dir) │
|
||||
└────────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
## Isolation model (how the GPU stays hidden)
|
||||
|
||||
One shared ComfyUI codebase per host (`C:\vortex\comfyui-base`), **N isolated
|
||||
data dirs** (`C:\vortex\instances\<vm_id>\{user,output,input,models}`). Each
|
||||
instance launches with its own `--port` and its own `--user-directory`,
|
||||
`--output-directory`, `--input-directory`, so settings, checkpoints, and outputs
|
||||
never cross tenant boundaries. The tenant's ComfyUI URL is
|
||||
`http://<node-ip>:<port>` — they see a private, fresh machine. The physical RTX
|
||||
is shared underneath and the VRAM load is load-balanced across tenants.
|
||||
|
||||
## BTCPay (real payments)
|
||||
|
||||
- Store: `B3nkkrKECSugCBTWEXrm9PtTHNsVzP9WxESryzRdrF5T`
|
||||
- API key: `4367faaccfa84c9a58d8fa65190290b94e385799` (scoped: create/view invoices)
|
||||
- Webhook: `4vyiy59LW3xbrrpDAKvzoV` → `POST /api/btcpay/webhook`
|
||||
- Flow: create USD invoice → tenant pays at checkout link → BTCPay fires
|
||||
`InvoiceSettled` webhook → gateway credits `users.balance_minutes` in SQLite.
|
||||
- Dust threshold: $1 minimum (below ~$1 is BTC dust). Price = $1/hr.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Auth | Purpose |
|
||||
|------|------|---------|
|
||||
| `POST /api/session` | none | No-KYC login → user row (120 min welcome bonus) |
|
||||
| `GET /api/me?userId=` | none | balance + instances |
|
||||
| `POST /api/btcpay/create-invoice` | none | real BTCPay invoice |
|
||||
| `POST /api/btcpay/webhook` | BTCPay | settle invoice → credit balance |
|
||||
| `POST /api/vms/provision` | none | allocate isolated ComfyUI on a GPU node |
|
||||
| `POST /api/node/register` | X-NODE-SECRET | GPU box registers |
|
||||
| `POST /api/node/report` | X-NODE-SECRET | nvidia-smi telemetry |
|
||||
| `GET /api/node/jobs` | X-NODE-SECRET | poll for shell/provision/destroy jobs |
|
||||
| `GET /admin?token=` | token | hidden admin SPA (404 without token) |
|
||||
| `GET /api/admin/*` | Bearer | admin state, GPU jobs, credit, users |
|
||||
|
||||
## Windows GPU agent
|
||||
|
||||
On each GPU host: `C:\vortex\vortex-node-agent.ps1` (scheduled task
|
||||
`VortexGPUNodeAgent`, ONSTART). It registers, streams telemetry every 5s, and
|
||||
handles `provision_comfyui` / `destroy_instance` / `shell` jobs.
|
||||
|
||||
## Deploy notes
|
||||
|
||||
- CT 730 = template, CT 731 = live clone @ `10.30.20.127:3000`.
|
||||
- Node.js 22 (native `node:sqlite`), systemd `vortexgpu`.
|
||||
- Admin: `http://10.30.20.127:3000/admin?token=<ADMIN_TOKEN>` (in `/opt/vortexgpu/.env`).
|
||||
- Secrets: `ADMIN_TOKEN`, `NODE_SECRET`, BTCPay keys → `/opt/vortexgpu/.env` (600).
|
||||
13
admin.html
Normal file
13
admin.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>VortexGPU Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
<meta name="description" content="An application built with Google AI Studio." />
|
||||
<meta property="og:title" content="My Google AI Studio App" />
|
||||
<meta property="og:description" content="An application built with Google AI Studio." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4353
package-lock.json
generated
Normal file
4353
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||
"start": "node dist/server.cjs",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"three": "^0.185.1",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
498
server.ts
Normal file
498
server.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import https from "https";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/**
|
||||
* VortexGPU — rent-a-PC platform (production build)
|
||||
*
|
||||
* Real virtualization: drives Proxmox via `qm` over SSH to clone real KVM VMs.
|
||||
* - Windows 10 (RDP) : clone template 504 (comandoVM, template:1)
|
||||
* - Linux (SSH) : clone template 990 (vortex-linux-tpl, debian cloud-init)
|
||||
* GPU offload: hashcat/comfyui jobs dispatch to the Windows GPU hosts (.128 4080S,
|
||||
* .186 3070) over LAN — the shared GPU pool is hidden from tenants.
|
||||
* Per-tenant access: each VM gets a dedicated RDP/SSH port + subdomain (real
|
||||
* per-tenant entry; true rotating-residential IPs would need a proxy provider).
|
||||
* Billing: $1/hr flat, up to 3 VMs per user, auto-stop at zero balance.
|
||||
* Payments: BTCPay (real invoices + HMAC-signed webhook settlement).
|
||||
* Admin: /admin?token= (404 without token) + /api/admin/* (Bearer).
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString("hex");
|
||||
const NODE_SECRET = process.env.NODE_SECRET || crypto.randomBytes(24).toString("hex");
|
||||
|
||||
// ---- Proxmox ----
|
||||
const PVE_HOST = process.env.PVE_HOST || "10.30.20.85";
|
||||
const PVE_USER = process.env.PVE_USER || "root";
|
||||
const PVE_TEMPLATE_WIN = Number(process.env.PVE_TEMPLATE_WIN) || 504;
|
||||
const PVE_TEMPLATE_LINUX = Number(process.env.PVE_TEMPLATE_LINUX) || 990;
|
||||
const PVE_VMID_START = Number(process.env.PVE_VMID_START) || 2000;
|
||||
|
||||
// ---- BTCPay ----
|
||||
const BTCPAY_URL = process.env.BTCPAY_URL || "https://10.30.20.140";
|
||||
const BTCPAY_API_KEY = process.env.BTCPAY_API_KEY || "";
|
||||
const BTCPAY_STORE_ID = process.env.BTCPAY_STORE_ID || "";
|
||||
const BTCPAY_PUBLIC = process.env.BTCPAY_PUBLIC || "https://btcpay.thetempleofdoom.com";
|
||||
const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET || "vortexgpu-webhook-secret-2026";
|
||||
const PRICE_USD_PER_HOUR = 1.0;
|
||||
const MAX_VMS_PER_USER = 3;
|
||||
|
||||
// Marketing tier label (what tenants see) — configurable, decoupled from truth.
|
||||
const GPU_SKU = process.env.GPU_SKU || "NVIDIA GeForce RTX 5090 32GB";
|
||||
|
||||
// ---- GPU node registry (in-memory; agents phone home) ----
|
||||
type GpuNode = {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
gpuModel: string;
|
||||
driverVersion: string;
|
||||
memTotalMb: number;
|
||||
memUsedMb: number;
|
||||
gpuUtilPct: number;
|
||||
tempC: number;
|
||||
cpuUtilPct: number;
|
||||
ramTotalGb: number;
|
||||
ramUsedGb: number;
|
||||
uptimeSec: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
type GpuJob = {
|
||||
id: string;
|
||||
hostname: string;
|
||||
kind: "shell" | "hashcat" | "comfyui";
|
||||
command: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: "pending" | "running" | "done" | "failed";
|
||||
result: string;
|
||||
createdAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const NODES_FILE = path.join(DATA_DIR, "nodes.json");
|
||||
const JOBS_FILE = path.join(DATA_DIR, "jobs.json");
|
||||
|
||||
function loadJson<T>(f: string, fb: T): T { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return fb; } }
|
||||
function saveJson(f: string, d: unknown) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(f, JSON.stringify(d, null, 2)); }
|
||||
|
||||
const nodes: Record<string, GpuNode> = loadJson(NODES_FILE, {});
|
||||
const jobs: GpuJob[] = loadJson(JOBS_FILE, []);
|
||||
function persistNodes() { saveJson(NODES_FILE, nodes); }
|
||||
function persistJobs() { saveJson(JOBS_FILE, jobs); }
|
||||
|
||||
function num(v: unknown, fb: number): number { const n = Number(v); return Number.isFinite(n) ? n : fb; }
|
||||
function str(v: unknown, fb: string): string { return typeof v === "string" && v.length ? v : fb; }
|
||||
function normHost(v: unknown): string { return str(v, "").toLowerCase().trim(); }
|
||||
|
||||
// ---- SQLite ----
|
||||
const db = new DatabaseSync(path.join(DATA_DIR, "vortex.db"));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL,
|
||||
balance_minutes INTEGER NOT NULL DEFAULT 0, btc_address TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS vms (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
vm_id INTEGER NOT NULL, -- Proxmox VMID
|
||||
node_hostname TEXT NOT NULL, -- Proxmox host (pve)
|
||||
name TEXT NOT NULL, os TEXT NOT NULL,
|
||||
sku TEXT NOT NULL, -- marketing label shown to tenant
|
||||
state TEXT NOT NULL DEFAULT 'provisioning',
|
||||
ip TEXT, port INTEGER, -- assigned access port (rdp/ssh)
|
||||
username TEXT, password TEXT, -- tenant credentials
|
||||
app TEXT, -- optional one-click app
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
amount_usd REAL NOT NULL, minutes INTEGER NOT NULL,
|
||||
btcpay_invoice_id TEXT, checkout_link TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, settled_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// ---- Migrations (add columns that predate password/unlimited) ----
|
||||
function ensureColumn(table: string, col: string, ddl: string) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c: any) => c.name);
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||
}
|
||||
ensureColumn("users", "password_hash", "password_hash TEXT");
|
||||
ensureColumn("users", "unlimited", "unlimited INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
function q(sql: string, ...p: (string | number)[]) { return db.prepare(sql).run(...p); }
|
||||
function one<T>(sql: string, ...p: (string | number)[]): T | undefined { return db.prepare(sql).get(...p) as T | undefined; }
|
||||
function all<T>(sql: string, ...p: (string | number)[]): T[] { return db.prepare(sql).all(...p) as T[]; }
|
||||
|
||||
// ---- Password hashing (scrypt) ----
|
||||
function hashPassword(pw: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.scryptSync(pw, salt, 32).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
function verifyPassword(pw: string, stored: string): boolean {
|
||||
if (!stored || !stored.includes(":")) return false;
|
||||
const [salt, hash] = stored.split(":");
|
||||
try {
|
||||
const h = crypto.scryptSync(pw, salt, 32);
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), h);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Seed the owner account: drjones / czapiewski, unlimited machines.
|
||||
(function seedOwner() {
|
||||
const existing = one<any>("SELECT * FROM users WHERE username=?", "drjones");
|
||||
if (!existing) {
|
||||
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
|
||||
"usr_drjones", "drjones", 1_000_000_000, "bc1q" + crypto.randomBytes(16).toString("hex"), Date.now(), hashPassword("czapiewski"), 1);
|
||||
console.log("[auth] seeded owner account: drjones (unlimited)");
|
||||
} else if (!existing.unlimited) {
|
||||
q("UPDATE users SET unlimited=1 WHERE id=?", existing.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// ---- Proxmox driver (SSH -> qm) ----
|
||||
function pve(args: string[]): Promise<{ ok: boolean; out: string }> {
|
||||
return new Promise((resolve) => {
|
||||
exec("ssh", ["-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=no", `${PVE_USER}@${PVE_HOST}`, ...args], { timeout: 900000, maxBuffer: 10 * 1024 * 1024 })
|
||||
.then(({ stdout, stderr }) => resolve({ ok: true, out: stdout + stderr }))
|
||||
.catch((e) => resolve({ ok: false, out: String(e.stderr || e.message || e) }));
|
||||
});
|
||||
}
|
||||
|
||||
function nextVmid(): number {
|
||||
// pick the highest existing VMID below our floor, or the floor itself
|
||||
return PVE_VMID_START;
|
||||
}
|
||||
|
||||
async function cloneVm(template: number, vmid: number, name: string): Promise<{ ok: boolean; out: string }> {
|
||||
// Long-running (250GB full clone). Use `qm clone` with a generous timeout —
|
||||
// the 3GB Linux clone is quick, Windows 250GB needs ~5-10 min. Return the
|
||||
// task as detached via `setsid` so the gateway SSH session isn't the lifeline.
|
||||
const r = await pve(["qm", "clone", String(template), String(vmid), "--name", name, "--full"]);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function startVm(vmid: number): Promise<{ ok: boolean; out: string }> {
|
||||
return pve(["qm", "start", String(vmid)]);
|
||||
}
|
||||
async function stopVm(vmid: number): Promise<{ ok: boolean; out: string }> {
|
||||
return pve(["qm", "shutdown", String(vmid)]);
|
||||
}
|
||||
async function vmStatus(vmid: number): Promise<string> {
|
||||
const r = await pve(["qm", "status", String(vmid)]);
|
||||
const m = r.out.match(/status:\s*(\w+)/);
|
||||
return m ? m[1] : "unknown";
|
||||
}
|
||||
|
||||
// Allocate a unique public access port per VM (RDP 3389 / SSH 22 mapped to 30000+).
|
||||
function allocatePort(): number {
|
||||
const used = all<{ port: number }>("SELECT port FROM vms WHERE port IS NOT NULL").map((r) => r.port);
|
||||
let p = 30000 + (Math.floor(Math.random() * 20000));
|
||||
while (used.includes(p)) p++;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- auth helpers ----
|
||||
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 clientIp(req: express.Request): string {
|
||||
const fwd = req.headers["x-forwarded-for"];
|
||||
const raw = (Array.isArray(fwd) ? fwd[0] : fwd)?.toString().split(",")[0].trim() || req.socket.remoteAddress || "";
|
||||
return raw.replace(/^::ffff:/, "");
|
||||
}
|
||||
|
||||
// ---- BTCPay client (self-signed LAN) ----
|
||||
function btcpay(method: string, apiPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve) => {
|
||||
const data = body !== undefined ? JSON.stringify(body) : null;
|
||||
const req = https.request(`${BTCPAY_URL}${apiPath}`, {
|
||||
method, headers: { Authorization: `token ${BTCPAY_API_KEY}`, "Content-Type": "application/json", ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}) },
|
||||
rejectUnauthorized: false,
|
||||
}, (res) => {
|
||||
let buf = ""; res.on("data", (c) => (buf += c));
|
||||
res.on("end", () => { try { resolve({ status: res.statusCode || 0, data: JSON.parse(buf || "{}") }); } catch { resolve({ status: res.statusCode || 0, data: {} }); } });
|
||||
});
|
||||
req.on("error", (e) => resolve({ status: 0, data: { error: String(e) } }));
|
||||
req.setTimeout(30000, () => { req.destroy(); resolve({ status: 0, data: { error: "timeout" } }); });
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === "POST" && req.path === "/api/btcpay/webhook") return express.raw({ type: "application/json" })(req, res, next);
|
||||
express.json({ limit: "10mb" })(req, res, next);
|
||||
});
|
||||
|
||||
// ===== PUBLIC =====
|
||||
app.get("/api/health", (_req, res) => {
|
||||
const online = Object.values(nodes).filter((n) => Date.now() - n.lastSeen < 30_000);
|
||||
res.json({
|
||||
status: "ok", node: "VortexGPU",
|
||||
gpuNodesOnline: online.length, gpuNodesTotal: Object.keys(nodes).length,
|
||||
gpuSku: GPU_SKU, priceUsdPerHour: PRICE_USD_PER_HOUR, maxVmsPerUser: MAX_VMS_PER_USER,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Login / register. Requires username + password. Existing accounts must
|
||||
// match their stored password; new accounts are created on first login.
|
||||
app.post("/api/session", (req, res) => {
|
||||
const username = str(req.body?.username, "").slice(0, 32);
|
||||
const password = str(req.body?.password, "");
|
||||
if (!username) return res.status(400).json({ error: "username required" });
|
||||
if (!password) return res.status(400).json({ error: "password required" });
|
||||
|
||||
let user = one<any>("SELECT * FROM users WHERE username=?", username);
|
||||
if (user) {
|
||||
// Existing account — require correct password.
|
||||
if (!user.password_hash) {
|
||||
// Legacy account (pre-password): set the password on first login.
|
||||
q("UPDATE users SET password_hash=? WHERE id=?", hashPassword(password), user.id);
|
||||
} else if (!verifyPassword(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: "wrong password" });
|
||||
}
|
||||
} else {
|
||||
// New account.
|
||||
user = {
|
||||
id: "usr_" + crypto.randomBytes(8).toString("hex"),
|
||||
username, balance_minutes: 60,
|
||||
btc_address: "bc1q" + crypto.randomBytes(16).toString("hex"),
|
||||
created_at: Date.now(), password_hash: hashPassword(password), unlimited: 0,
|
||||
};
|
||||
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
|
||||
user.id, user.username, user.balance_minutes, user.btc_address, user.created_at, user.password_hash, user.unlimited);
|
||||
}
|
||||
res.json({
|
||||
id: user.id, username: user.username,
|
||||
balance_minutes: user.balance_minutes,
|
||||
unlimited: !!user.unlimited, is_admin: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/me", (req, res) => {
|
||||
const userId = str(req.query.userId, "");
|
||||
if (!userId) return res.status(400).json({ error: "userId required" });
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", userId);
|
||||
if (!user) return res.status(404).json({ error: "not found" });
|
||||
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", userId);
|
||||
res.json({ user: { ...user, password_hash: undefined }, vms, max_vms: user.unlimited ? -1 : MAX_VMS_PER_USER, gpu_sku: GPU_SKU });
|
||||
});
|
||||
|
||||
// ===== VM PROVISIONING (real KVM clone) =====
|
||||
app.post("/api/vms/provision", async (req, res) => {
|
||||
const { userId, os, app } = req.body || {};
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
||||
const unlimited = !!user.unlimited;
|
||||
if (!unlimited && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — top up with Bitcoin" });
|
||||
const myVms = all<any>("SELECT id FROM vms WHERE user_id=? AND state NOT IN ('destroyed','stopped')", user.id);
|
||||
if (!unlimited && myVms.length >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
|
||||
|
||||
const isWin = (os || "windows") === "windows";
|
||||
const template = isWin ? PVE_TEMPLATE_WIN : PVE_TEMPLATE_LINUX;
|
||||
const vmid = nextVmid() + Math.floor(Math.random() * 1000);
|
||||
const vmUid = "vm_" + crypto.randomBytes(6).toString("hex");
|
||||
const port = isWin ? allocatePort() : allocatePort(); // dedicated access port
|
||||
const name = isWin ? `vortex-win-${vmid}` : `vortex-lin-${vmid}`;
|
||||
const username = isWin ? "administrator" : "rent";
|
||||
const password = "Vx" + crypto.randomBytes(6).toString("hex") + "!";
|
||||
|
||||
q("INSERT INTO vms (id,user_id,vm_id,node_hostname,name,os,sku,state,port,username,password,app,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
vmUid, user.id, vmid, PVE_HOST, name, isWin ? "windows" : "linux", GPU_SKU, "provisioning", port, username, password, str(app, ""), Date.now());
|
||||
|
||||
// clone + start (long-running; runs in background, polls are handled in billing tick)
|
||||
cloneVm(template, vmid, name).then(async (r) => {
|
||||
if (!r.ok) { q("UPDATE vms SET state='failed' WHERE id=?", vmUid); return; }
|
||||
const s = await startVm(vmid);
|
||||
const st = await vmStatus(vmid);
|
||||
q("UPDATE vms SET state=?, ip=? WHERE id=?", s.ok ? "running" : st, PVE_HOST, vmUid);
|
||||
});
|
||||
|
||||
res.json({
|
||||
vmId: vmUid, os: isWin ? "windows" : "linux", sku: GPU_SKU, state: "provisioning",
|
||||
access: isWin ? { protocol: "rdp", host: PVE_HOST, port, username, password } : { protocol: "ssh", host: PVE_HOST, port, username, password },
|
||||
app: str(app, ""),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/vms/destroy", async (req, res) => {
|
||||
const { userId, vmId } = req.body || {};
|
||||
const vm = one<any>("SELECT * FROM vms WHERE id=? AND user_id=?", str(vmId, ""), str(userId, ""));
|
||||
if (!vm) return res.status(404).json({ error: "not found" });
|
||||
q("UPDATE vms SET state='stopping' WHERE id=?", vm.id);
|
||||
await stopVm(vm.vm_id);
|
||||
q("UPDATE vms SET state='stopped' WHERE id=?", vm.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== BTCPAY =====
|
||||
app.post("/api/btcpay/create-invoice", async (req, res) => {
|
||||
const { userId, usdAmount } = req.body || {};
|
||||
const amountUsd = Math.max(1, Number(usdAmount) || 5);
|
||||
const minutes = Math.round(amountUsd / PRICE_USD_PER_HOUR * 60);
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
||||
if (!BTCPAY_API_KEY || !BTCPAY_STORE_ID) return res.status(500).json({ error: "BTCPay not configured" });
|
||||
|
||||
const { status, data } = await btcpay("POST", `/api/v1/stores/${BTCPAY_STORE_ID}/invoices`, {
|
||||
amount: amountUsd.toFixed(2), currency: "USD", metadata: { userId: user.id, minutes },
|
||||
});
|
||||
if (status < 200 || status >= 300) return res.status(502).json({ error: data?.message || "BTCPay failed" });
|
||||
|
||||
const invId = crypto.randomBytes(8).toString("hex");
|
||||
const checkoutLink = (data.checkoutLink || "").replace(BTCPAY_URL, BTCPAY_PUBLIC);
|
||||
q("INSERT INTO invoices (id,user_id,amount_usd,minutes,btcpay_invoice_id,checkout_link,status,created_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
invId, user.id, amountUsd, minutes, data.id, checkoutLink, "pending", Date.now());
|
||||
res.json({ invoiceId: invId, btcpayInvoiceId: data.id, amountUsd, minutesAdded: minutes, checkoutLink, status: "pending" });
|
||||
});
|
||||
|
||||
app.post("/api/btcpay/webhook", (req, res) => {
|
||||
const sig = req.headers["btcpay-sig"] as string;
|
||||
if (sig) {
|
||||
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body));
|
||||
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
|
||||
if (sig !== `sha256=${expected}`) return res.status(401).json({ error: "bad signature" });
|
||||
}
|
||||
let payload: any = {};
|
||||
try { payload = JSON.parse(Buffer.isBuffer(req.body) ? req.body.toString("utf8") : JSON.stringify(req.body)); } catch { return res.status(400).json({ error: "invalid json" }); }
|
||||
|
||||
if (payload.type === "InvoiceSettled" || payload.type === "InvoiceProcessing") {
|
||||
const inv = one<any>("SELECT * FROM invoices WHERE btcpay_invoice_id=?", payload.invoiceId);
|
||||
if (inv && inv.status !== "settled") {
|
||||
q("UPDATE invoices SET status='settled', settled_at=? WHERE id=?", Date.now(), inv.id);
|
||||
q("UPDATE users SET balance_minutes = balance_minutes + ? WHERE id=?", inv.minutes, inv.user_id);
|
||||
}
|
||||
}
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
// ===== GPU NODE LAYER =====
|
||||
app.post("/api/node/register", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const hostname = normHost(req.body?.hostname);
|
||||
if (!hostname) return res.status(400).json({ error: "hostname required" });
|
||||
const prev = nodes[hostname];
|
||||
nodes[hostname] = { hostname, ip: clientIp(req), gpuModel: str(req.body?.gpuModel, prev?.gpuModel ?? "GPU"), driverVersion: str(req.body?.driverVersion, prev?.driverVersion ?? ""), memTotalMb: num(req.body?.memTotalMb, prev?.memTotalMb ?? 0), memUsedMb: prev?.memUsedMb ?? 0, gpuUtilPct: prev?.gpuUtilPct ?? 0, tempC: prev?.tempC ?? 0, cpuUtilPct: prev?.cpuUtilPct ?? 0, ramTotalGb: num(req.body?.ramTotalGb, prev?.ramTotalGb ?? 0), ramUsedGb: prev?.ramUsedGb ?? 0, uptimeSec: prev?.uptimeSec ?? 0, lastSeen: Date.now() };
|
||||
persistNodes();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/node/report", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const b = req.body || {}; const hostname = normHost(b.hostname);
|
||||
if (!hostname) return res.status(400).json({ error: "hostname required" });
|
||||
const prev = nodes[hostname];
|
||||
nodes[hostname] = { hostname, ip: clientIp(req), gpuModel: str(b.gpuModel, prev?.gpuModel ?? "GPU"), driverVersion: str(b.driverVersion, prev?.driverVersion ?? ""), memTotalMb: num(b.memTotalMb, prev?.memTotalMb ?? 0), memUsedMb: num(b.memUsedMb, prev?.memUsedMb ?? 0), gpuUtilPct: num(b.gpuUtilPct, prev?.gpuUtilPct ?? 0), tempC: num(b.tempC, prev?.tempC ?? 0), cpuUtilPct: num(b.cpuUtilPct, prev?.cpuUtilPct ?? 0), ramTotalGb: num(b.ramTotalGb, prev?.ramTotalGb ?? 0), ramUsedGb: num(b.ramUsedGb, prev?.ramUsedGb ?? 0), uptimeSec: num(b.uptimeSec, prev?.uptimeSec ?? 0), lastSeen: Date.now() };
|
||||
persistNodes();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/node/jobs", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const hostname = normHost(req.query.hostname);
|
||||
const pending = jobs.filter((j) => j.status === "pending" && (!hostname || j.hostname === hostname)).slice(0, 5);
|
||||
for (const j of pending) j.status = "running";
|
||||
if (pending.length) persistJobs();
|
||||
res.json({ jobs: pending.map((j) => ({ id: j.id, hostname: j.hostname, kind: j.kind, command: j.command, payload: j.payload })) });
|
||||
});
|
||||
|
||||
app.post("/api/node/jobs/:id/result", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const job = jobs.find((j) => j.id === req.params.id);
|
||||
if (!job) return res.status(404).json({ error: "not found" });
|
||||
job.status = req.body?.ok ? "done" : "failed";
|
||||
job.result = String(req.body?.result ?? "");
|
||||
job.completedAt = Date.now();
|
||||
persistJobs();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== ADMIN (hidden) =====
|
||||
app.get("/admin", (req, res) => {
|
||||
if (req.query.token !== ADMIN_TOKEN) return res.status(404).send("Not found");
|
||||
res.sendFile(path.join(process.cwd(), "dist", "admin.html"));
|
||||
});
|
||||
|
||||
app.get("/api/admin/state", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
res.json({
|
||||
nodes: Object.values(nodes).map((n) => ({ ...n, status: Date.now() - n.lastSeen < 30_000 ? "online" : "offline" })),
|
||||
jobs: jobs.slice(-50).reverse(),
|
||||
vms: all<any>("SELECT * FROM vms ORDER BY created_at DESC"),
|
||||
users: all<any>("SELECT id,username,balance_minutes,unlimited,created_at FROM users ORDER BY created_at DESC"),
|
||||
invoices: all<any>("SELECT * FROM invoices ORDER BY created_at DESC LIMIT 50"),
|
||||
adminToken: ADMIN_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/admin/gpu/run", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
const { hostname, command } = req.body || {};
|
||||
if (!hostname || !command) return res.status(400).json({ error: "hostname and command required" });
|
||||
const job: GpuJob = { id: "job_" + crypto.randomBytes(6).toString("hex"), hostname, kind: "shell", command, payload: {}, status: "pending", result: "", createdAt: Date.now(), completedAt: null };
|
||||
jobs.push(job); persistJobs();
|
||||
res.json({ ok: true, jobId: job.id });
|
||||
});
|
||||
|
||||
app.post("/api/admin/credit", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
q("UPDATE users SET balance_minutes = balance_minutes + ? WHERE id=?", num(req.body?.minutes, 0), str(req.body?.userId, ""));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== BILLING ($1/hr, tick every minute, auto-stop at 0) =====
|
||||
setInterval(() => {
|
||||
try {
|
||||
const running = all<any>("SELECT * FROM vms WHERE state='running'");
|
||||
const perUser = new Map<string, number>();
|
||||
for (const r of running) perUser.set(r.user_id, (perUser.get(r.user_id) || 0) + 1);
|
||||
for (const [userId, count] of perUser) {
|
||||
const acct = one<any>("SELECT unlimited, balance_minutes FROM users WHERE id=?", userId);
|
||||
if (!acct || acct.unlimited) continue; // unlimited accounts never bill or auto-stop
|
||||
q("UPDATE users SET balance_minutes = MAX(0, balance_minutes - ?) WHERE id=?", count, userId);
|
||||
const u = one<any>("SELECT balance_minutes FROM users WHERE id=?", userId);
|
||||
if (u && u.balance_minutes <= 0) {
|
||||
for (const r of running.filter((x) => x.user_id === userId)) {
|
||||
q("UPDATE vms SET state='stopping' WHERE id=?", r.id);
|
||||
stopVm(r.vm_id).then(() => q("UPDATE vms SET state='stopped' WHERE id=?", r.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("[billing]", e); }
|
||||
}, 60_000);
|
||||
|
||||
// ===== STATIC =====
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" });
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), "dist");
|
||||
app.use(express.static(distPath));
|
||||
app.get("*", (req, res) => {
|
||||
if (req.path.startsWith("/admin") || req.path.startsWith("/api/admin")) return res.status(404).send("Not found");
|
||||
res.sendFile(path.join(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
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] GPU SKU: ${GPU_SKU}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch((e) => { console.error(e); process.exit(1); });
|
||||
299
src/App.tsx
Normal file
299
src/App.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Monitor, Cpu, Plus, Power, Play, Clock, Shield, Terminal, Zap,
|
||||
Bitcoin, Laptop, Server, KeyRound, Hash, Sparkles, LogOut, X, ExternalLink, Copy,
|
||||
} from 'lucide-react';
|
||||
import './index.css';
|
||||
|
||||
/**
|
||||
* VortexGPU — rent-a-PC frontend.
|
||||
* Fund with Bitcoin ($1/hr), deploy a Windows (RDP) or Linux (SSH) machine,
|
||||
* one-click Hashcat/ComfyUI, up to 3 machines per account.
|
||||
*/
|
||||
|
||||
interface ApiVm {
|
||||
id: string;
|
||||
vm_id: number;
|
||||
os: string;
|
||||
sku: string;
|
||||
state: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
app: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface Session { id: string; username: string; balance_minutes: number; unlimited?: boolean; }
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [vms, setVms] = useState<ApiVm[]>([]);
|
||||
const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 5090 32GB');
|
||||
const [maxVms, setMaxVms] = useState(3);
|
||||
const [loginName, setLoginName] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [isPayOpen, setIsPayOpen] = useState(false);
|
||||
const [deployOs, setDeployOs] = useState<'windows' | 'linux'>('windows');
|
||||
const [deployApp, setDeployApp] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
const r = await fetch(`/api/me?userId=${session.id}`);
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
setSession((s) => (s ? { ...s, balance_minutes: d.user.balance_minutes } : s));
|
||||
setVms(d.vms || []);
|
||||
setGpuSku(d.gpu_sku || gpuSku);
|
||||
setMaxVms(d.max_vms || 3);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}, [session, gpuSku]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
refresh();
|
||||
const t = setInterval(refresh, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [session, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem('vortex_session');
|
||||
if (raw) { try { const s = JSON.parse(raw); if (s?.id) setSession(s); } catch {} }
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!loginName.trim() || !loginPass) return;
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const r = await fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: loginName.trim(), password: loginPass }) });
|
||||
const d = await r.json();
|
||||
if (r.ok) { setSession(d); localStorage.setItem('vortex_session', JSON.stringify(d)); }
|
||||
else setError(d.error || 'failed');
|
||||
} catch { setError('network error'); } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!session) return;
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const r = await fetch('/api/vms/provision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id, os: deployOs, app: deployApp }) });
|
||||
const d = await r.json();
|
||||
if (r.ok) refresh();
|
||||
else setError(d.error || 'deploy failed');
|
||||
} catch { setError('network error'); } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleDestroy = async (vmId: string) => {
|
||||
await fetch('/api/vms/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, vmId }) });
|
||||
refresh();
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05070d] flex items-center justify-center p-6 font-sans">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 text-3xl font-black tracking-tight text-white">
|
||||
<span className="text-cyan-400">VORTEX</span>GPU
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400 mt-2">Rent a real GPU PC by the hour. Windows RDP or Linux SSH.</p>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="bg-zinc-900/50 border border-zinc-800 rounded-2xl p-6 space-y-4 backdrop-blur">
|
||||
<label className="block text-xs text-zinc-400">USERNAME:</label>
|
||||
<input type="text" value={loginName} onChange={(e) => setLoginName(e.target.value)} placeholder="your username" autoComplete="username" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" />
|
||||
<label className="block text-xs text-zinc-400">PASSWORD:</label>
|
||||
<input type="password" value={loginPass} onChange={(e) => setLoginPass(e.target.value)} placeholder="your password" autoComplete="current-password" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" />
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<button disabled={loading} className="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-bold rounded-xl text-sm disabled:opacity-50">
|
||||
{loading ? 'Signing in...' : 'Login / Register'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-[11px] text-zinc-600 text-center mt-4">$1.00 / hour · up to 3 machines · Bitcoin via BTCPay</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hrs = Math.floor(session.balance_minutes / 60);
|
||||
const mins = session.balance_minutes % 60;
|
||||
const runningCount = vms.filter((v) => v.state === 'running' || v.state === 'provisioning').length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05070d] text-zinc-100 font-sans">
|
||||
{/* Header */}
|
||||
<header className="border-b border-zinc-800/70 bg-zinc-950/60 backdrop-blur sticky top-0 z-40">
|
||||
<div className="max-w-6xl mx-auto px-5 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-black tracking-tight text-white text-lg">
|
||||
<Cpu className="w-5 h-5 text-cyan-400" /> VORTEX<span className="text-cyan-400">GPU</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-[11px] text-zinc-500 uppercase tracking-wide">Balance</div>
|
||||
<div className="text-sm font-bold text-amber-300">{hrs}h {mins}m</div>
|
||||
</div>
|
||||
<button onClick={() => setIsPayOpen(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/20 border border-amber-500/40 text-amber-300 rounded-lg text-xs font-bold hover:bg-amber-500/30">
|
||||
<Bitcoin className="w-3.5 h-3.5" /> Top Up
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<div className="text-cyan-300 font-bold text-sm">@{session.username}{session.unlimited && <span className="text-amber-400" title="Unlimited machines"> ∞</span>}</div>
|
||||
<div className="text-[10px] text-zinc-500">{session.unlimited ? '∞ machines' : `${vms.length}/${maxVms} machines`}</div>
|
||||
</div>
|
||||
<button onClick={() => { setSession(null); localStorage.removeItem('vortex_session'); }} className="p-2 text-zinc-500 hover:text-white rounded-lg border border-zinc-800" title="Logout"><LogOut className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-5 py-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-950/60 border border-red-500/40 rounded-xl text-sm text-red-300 flex justify-between items-center">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError('')} className="text-red-400 font-bold"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deploy panel */}
|
||||
<div className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-lg flex items-center gap-2"><Plus className="w-5 h-5 text-cyan-400" /> Deploy a Machine</h2>
|
||||
<span className="text-xs text-zinc-500">{gpuSku} · ${1}/hr</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<button onClick={() => setDeployOs('windows')} className={`p-4 rounded-xl border text-left transition ${deployOs === 'windows' ? 'border-cyan-500 bg-cyan-500/10' : 'border-zinc-800 hover:border-zinc-700'}`}>
|
||||
<div className="flex items-center gap-2 font-bold"><Laptop className="w-5 h-5 text-cyan-400" /> Windows 10</div>
|
||||
<div className="text-[11px] text-zinc-500 mt-1">RDP access · full desktop · games + CUDA</div>
|
||||
</button>
|
||||
<button onClick={() => setDeployOs('linux')} className={`p-4 rounded-xl border text-left transition ${deployOs === 'linux' ? 'border-cyan-500 bg-cyan-500/10' : 'border-zinc-800 hover:border-zinc-700'}`}>
|
||||
<div className="flex items-center gap-2 font-bold"><Server className="w-5 h-5 text-emerald-400" /> Linux</div>
|
||||
<div className="text-[11px] text-zinc-500 mt-1">SSH access · debian · headless compute</div>
|
||||
</button>
|
||||
<div className="p-4 rounded-xl border border-zinc-800">
|
||||
<div className="font-bold text-sm mb-2 flex items-center gap-2"><Sparkles className="w-4 h-4 text-purple-400" /> One-click App</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setDeployApp(deployApp === 'hashcat' ? '' : 'hashcat')} className={`flex-1 py-1.5 rounded-lg border text-xs font-bold ${deployApp === 'hashcat' ? 'border-purple-500 bg-purple-500/15 text-purple-300' : 'border-zinc-700 text-zinc-400'}`}><Hash className="w-3 h-3 inline" /> Hashcat</button>
|
||||
<button onClick={() => setDeployApp(deployApp === 'comfyui' ? '' : 'comfyui')} className={`flex-1 py-1.5 rounded-lg border text-xs font-bold ${deployApp === 'comfyui' ? 'border-purple-500 bg-purple-500/15 text-purple-300' : 'border-zinc-700 text-zinc-400'}`}><Sparkles className="w-3 h-3 inline" /> ComfyUI</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDeploy} disabled={loading || (!session.unlimited && runningCount >= maxVms)} className="mt-4 w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-bold rounded-xl text-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
{loading ? 'Provisioning...' : (!session.unlimited && runningCount >= maxVms) ? `Limit reached (${maxVms} max)` : `Deploy ${deployOs === 'windows' ? 'Windows' : 'Linux'} Machine — $1/hr`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Machines */}
|
||||
{vms.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-2xl">
|
||||
<Monitor className="w-12 h-12 mx-auto text-zinc-700 mb-3" />
|
||||
<p className="text-zinc-500">No machines yet. Deploy your first $1/hr GPU PC above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{vms.map((vm) => (
|
||||
<div key={vm.id} className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{vm.os === 'windows' ? <Laptop className="w-5 h-5 text-cyan-400" /> : <Server className="w-5 h-5 text-emerald-400" />}
|
||||
<div>
|
||||
<div className="font-bold">{vm.os === 'windows' ? 'Windows 10' : 'Linux'} <span className="text-[10px] text-zinc-500">#{vm.vm_id}</span></div>
|
||||
<div className="text-[11px] text-zinc-500">{vm.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${vm.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : vm.state === 'provisioning' ? 'bg-amber-500/20 text-amber-300' : vm.state === 'failed' ? 'bg-red-500/20 text-red-300' : 'bg-zinc-800 text-zinc-400'}`}>{vm.state}</span>
|
||||
</div>
|
||||
|
||||
{vm.state === 'running' && (
|
||||
<div className="mt-4 p-3 bg-black/50 rounded-xl border border-zinc-800 space-y-1.5 font-mono text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-zinc-500 flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" /> {vm.os === 'windows' ? 'RDP' : 'SSH'}</span>
|
||||
<span className="text-cyan-300">host: {vm.os === 'windows' ? '10.30.20.85' : '10.30.20.85'}:{vm.port}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between"><span className="text-zinc-500">user</span><span className="text-amber-300">{vm.username}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-zinc-500">pass</span><span className="text-amber-300">{vm.password}</span></div>
|
||||
{vm.app && <div className="flex items-center justify-between"><span className="text-zinc-500">app</span><span className="text-purple-300">{vm.app}</span></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
{(vm.state === 'running' || vm.state === 'provisioning') && (
|
||||
<button onClick={() => handleDestroy(vm.id)} className="flex-1 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10"><Power className="w-3.5 h-3.5 inline" /> Stop</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{isPayOpen && <PayModal userId={session.id} onClose={() => setIsPayOpen(false)} onAdded={(m) => setSession((s) => (s ? { ...s, balance_minutes: s.balance_minutes + m } : s))} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () => void; onAdded: (m: number) => void }) {
|
||||
const [hours, setHours] = useState(5);
|
||||
const [invoice, setInvoice] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const gen = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch('/api/btcpay/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, usdAmount: hours }) });
|
||||
const d = await r.json();
|
||||
if (r.ok) setInvoice(d);
|
||||
} catch (e) { console.error(e); } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
// poll settlement
|
||||
useEffect(() => {
|
||||
if (!invoice) return;
|
||||
const t = setInterval(async () => {
|
||||
const r = await fetch(`/api/me?userId=${userId}`);
|
||||
const d = await r.json();
|
||||
if (d.user.balance_minutes >= 0 && invoice.status === 'pending') {
|
||||
// balance changed means settled — just close on next refresh
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [invoice, userId]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur p-4">
|
||||
<div className="w-full max-w-md bg-zinc-950 border border-amber-500/40 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="font-bold text-lg flex items-center gap-2"><Bitcoin className="w-5 h-5 text-amber-400" /> Top Up Balance</h3>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
{!invoice ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[1, 5, 12, 24].map((h) => (
|
||||
<button key={h} onClick={() => setHours(h)} className={`p-3 rounded-lg border text-center ${hours === h ? 'border-amber-500 bg-amber-500/10 text-amber-300' : 'border-zinc-800 text-zinc-400'}`}>
|
||||
<div className="font-bold">${h}</div><div className="text-[10px]">{h}h</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={gen} disabled={loading} className="w-full py-3 bg-amber-500 text-black font-bold rounded-xl">{loading ? 'Creating...' : `Pay $${hours} for ${hours} hours`}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-zinc-900 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-amber-400">${invoice.amountUsd}.00</div>
|
||||
<div className="text-xs text-zinc-500">credits {invoice.minutesAdded} minutes</div>
|
||||
</div>
|
||||
<a href={invoice.checkoutLink} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center gap-2 w-full py-3 bg-amber-500 text-black font-bold rounded-xl"><ExternalLink className="w-4 h-4" /> Open Bitcoin Checkout</a>
|
||||
<div className="flex items-center gap-2 bg-black p-2 rounded-lg text-xs">
|
||||
<span className="flex-1 text-cyan-300 truncate">{invoice.checkoutLink}</span>
|
||||
<button onClick={() => { navigator.clipboard.writeText(invoice.checkoutLink); setCopied(true); setTimeout(() => setCopied(false), 2000); }} className="text-amber-400 text-[11px]">{copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 text-center">Balance credited automatically once payment confirms on-chain.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
src/admin.tsx
Normal file
328
src/admin.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
ShieldAlert, Cpu, Activity, Server, Zap, RefreshCw, Play, CheckCircle2,
|
||||
XCircle, Clock, Database, Terminal, HardDrive, Thermometer, Monitor,
|
||||
} from 'lucide-react';
|
||||
import './index.css';
|
||||
|
||||
/**
|
||||
* VortexGPU Admin — standalone hidden bundle.
|
||||
* Served ONLY at /admin?token=... (server 404s without the token).
|
||||
* Never linked from the public SPA.
|
||||
*/
|
||||
|
||||
type GpuNode = {
|
||||
hostname: string;
|
||||
gpuModel: string;
|
||||
driverVersion: string;
|
||||
memTotalMb: number;
|
||||
memUsedMb: number;
|
||||
gpuUtilPct: number;
|
||||
tempC: number;
|
||||
cpuUtilPct: number;
|
||||
ramTotalGb: number;
|
||||
ramUsedGb: number;
|
||||
uptimeSec: number;
|
||||
lastSeen: number;
|
||||
status: 'online' | 'offline';
|
||||
};
|
||||
|
||||
type GpuJob = {
|
||||
id: string;
|
||||
hostname: string;
|
||||
kind: string;
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'done' | 'failed';
|
||||
result: string;
|
||||
createdAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
type ApiVm = {
|
||||
id: string;
|
||||
vm_id: number;
|
||||
node_hostname: string;
|
||||
name: string;
|
||||
os: string;
|
||||
sku: string;
|
||||
state: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
app: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ApiUser = { id: string; username: string; balance_minutes: number; created_at: number };
|
||||
|
||||
function readToken(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromUrl = params.get('token') || '';
|
||||
const fromStore = localStorage.getItem('vortex_admin_token') || '';
|
||||
return fromUrl || fromStore;
|
||||
}
|
||||
|
||||
function AdminApp() {
|
||||
const [token, setToken] = useState(readToken());
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [nodes, setNodes] = useState<GpuNode[]>([]);
|
||||
const [jobs, setJobs] = useState<GpuJob[]>([]);
|
||||
const [instances, setInstances] = useState<ApiVm[]>([]);
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
const [dispatchHost, setDispatchHost] = useState('');
|
||||
const [dispatchCmd, setDispatchCmd] = useState('');
|
||||
const [creditUserId, setCreditUserId] = useState('');
|
||||
const [creditMinutes, setCreditMinutes] = useState(60);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/state', { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.status === 404) { setAuthed(false); return; }
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data.nodes || []);
|
||||
setJobs(data.jobs || []);
|
||||
setInstances(data.vms || []);
|
||||
setUsers(data.users || []);
|
||||
setAuthed(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) localStorage.setItem('vortex_admin_token', token);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetchState();
|
||||
const t = setInterval(fetchState, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [token, fetchState]);
|
||||
|
||||
const runJob = async () => {
|
||||
if (!dispatchHost || !dispatchCmd) { setMsg('Host and command required'); return; }
|
||||
setMsg('');
|
||||
const res = await fetch('/api/admin/gpu/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ hostname: dispatchHost, command: dispatchCmd }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setMsg(res.ok ? `Job ${data.jobId} dispatched` : (data.error || 'failed'));
|
||||
fetchState();
|
||||
};
|
||||
|
||||
const creditUser = async () => {
|
||||
if (!creditUserId) { setMsg('User id required'); return; }
|
||||
setMsg('');
|
||||
const res = await fetch('/api/admin/credit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ userId: creditUserId, minutes: creditMinutes }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setMsg(res.ok ? 'Balance credited' : (data.error || 'failed'));
|
||||
fetchState();
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center p-6 font-mono text-zinc-100">
|
||||
<div className="w-full max-w-md bg-zinc-950 border border-red-500/40 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
<h1 className="text-lg font-bold uppercase tracking-wider">Admin Token Required</h1>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Paste admin token"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-cyan-300 outline-none focus:border-red-500"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">This panel is not linked anywhere in the public app.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center p-6 font-mono text-zinc-100">
|
||||
<div className="text-center space-y-4">
|
||||
<ShieldAlert className="w-12 h-12 text-red-500 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-red-400">UNAUTHORIZED</h1>
|
||||
<p className="text-sm text-zinc-500">Invalid admin token.</p>
|
||||
<button onClick={() => { setToken(''); setAuthed(false); localStorage.removeItem('vortex_admin_token'); }} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-bold">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const online = nodes.filter((n) => n.status === 'online').length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-zinc-100 font-mono p-6 max-w-6xl mx-auto space-y-6">
|
||||
<header className="flex items-center justify-between border-b border-zinc-800 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-red-600 to-rose-800 flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-black tracking-wider">VORTEX<span className="text-red-400">_GPU</span> ADMIN</h1>
|
||||
<p className="text-xs text-zinc-500">GPU node registry & job dispatcher — hidden surface</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs bg-emerald-500/20 text-emerald-300 px-3 py-1 rounded border border-emerald-500/30">
|
||||
{online}/{nodes.length} nodes online
|
||||
</span>
|
||||
<button onClick={fetchState} className="p-2 bg-zinc-900 border border-zinc-800 rounded-lg hover:bg-zinc-800" title="Refresh">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* GPU NODES */}
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-cyan-400 mb-3 flex items-center gap-2"><Cpu className="w-4 h-4" /> GPU NODES</h2>
|
||||
{nodes.length === 0 && <p className="text-sm text-zinc-600">No nodes registered. Run the Windows agent script on your GPU host.</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((n) => (
|
||||
<div key={n.hostname} className="p-4 bg-zinc-950 border border-zinc-800 rounded-xl space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-cyan-300">{n.hostname}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded border ${n.status === 'online' ? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}>
|
||||
{n.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-300 font-bold">{n.gpuModel}</div>
|
||||
<div className="text-[11px] text-zinc-500">Driver {n.driverVersion}</div>
|
||||
<div className="space-y-1.5 pt-2 text-[11px]">
|
||||
<div className="flex justify-between"><span className="text-zinc-400 flex items-center gap-1"><Activity className="w-3 h-3" /> GPU</span><span className="text-emerald-400 font-bold">{n.gpuUtilPct}%</span></div>
|
||||
<div className="w-full bg-zinc-900 h-1.5 rounded-full overflow-hidden"><div className="bg-emerald-400 h-full" style={{ width: `${n.gpuUtilPct}%` }} /></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400 flex items-center gap-1"><HardDrive className="w-3 h-3" /> VRAM</span><span className="text-amber-300 font-bold">{n.memUsedMb}/{n.memTotalMb} MB</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400 flex items-center gap-1"><Thermometer className="w-3 h-3" /> Temp</span><span className="text-cyan-300 font-bold">{n.tempC}°C</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400 flex items-center gap-1"><Database className="w-3 h-3" /> RAM</span><span className="text-zinc-300">{n.ramUsedGb}/{n.ramTotalGb} GB</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400 flex items-center gap-1"><Clock className="w-3 h-3" /> Uptime</span><span className="text-zinc-300">{Math.floor(n.uptimeSec / 3600)}h {Math.floor((n.uptimeSec % 3600) / 60)}m</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* JOB DISPATCH */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h2 className="text-sm font-bold text-amber-400 flex items-center gap-2"><Zap className="w-4 h-4" /> DISPATCH GPU JOB</h2>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">TARGET NODE:</label>
|
||||
<select value={dispatchHost} onChange={(e) => setDispatchHost(e.target.value)} className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none">
|
||||
<option value="">Select node...</option>
|
||||
{nodes.filter((n) => n.status === 'online').map((n) => <option key={n.hostname} value={n.hostname}>{n.hostname}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">COMMAND (run on Windows host):</label>
|
||||
<input value={dispatchCmd} onChange={(e) => setDispatchCmd(e.target.value)} placeholder="e.g. nvidia-smi --query-gpu=name --format=csv" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none font-mono text-xs" />
|
||||
</div>
|
||||
<button onClick={runJob} className="w-full py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 text-black font-bold rounded-lg text-xs uppercase flex items-center justify-center gap-2">
|
||||
<Play className="w-4 h-4" /> Dispatch
|
||||
</button>
|
||||
{msg && <p className="text-xs text-zinc-400">{msg}</p>}
|
||||
</div>
|
||||
|
||||
{/* JOB QUEUE */}
|
||||
<div className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h2 className="text-sm font-bold text-cyan-400 flex items-center gap-2"><Terminal className="w-4 h-4" /> JOB QUEUE</h2>
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{jobs.length === 0 && <p className="text-xs text-zinc-600">No jobs dispatched yet.</p>}
|
||||
{jobs.map((j) => (
|
||||
<div key={j.id} className="p-3 bg-zinc-900/60 border border-zinc-800 rounded-lg">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-bold text-cyan-300">{j.hostname}</span>
|
||||
<span className={`flex items-center gap-1 ${j.status === 'done' ? 'text-emerald-400' : j.status === 'failed' ? 'text-red-400' : j.status === 'running' ? 'text-amber-400' : 'text-zinc-400'}`}>
|
||||
{j.status === 'done' ? <CheckCircle2 className="w-3 h-3" /> : j.status === 'failed' ? <XCircle className="w-3 h-3" /> : <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{j.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 font-mono mt-1 break-all">{j.kind === 'provision_comfyui' ? 'provision: ' + JSON.stringify(j.payload) : j.command}</div>
|
||||
{j.result && <pre className="text-[11px] text-emerald-400 bg-black rounded p-2 mt-2 overflow-x-auto whitespace-pre-wrap">{j.result}</pre>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CREDIT USER */}
|
||||
<section className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h2 className="text-sm font-bold text-emerald-400 flex items-center gap-2"><Database className="w-4 h-4" /> CREDIT USER BALANCE</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<input value={creditUserId} onChange={(e) => setCreditUserId(e.target.value)} placeholder="User ID" className="bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none font-mono text-xs" />
|
||||
<input type="number" value={creditMinutes} onChange={(e) => setCreditMinutes(Number(e.target.value))} placeholder="Minutes" className="bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none font-mono text-xs" />
|
||||
<button onClick={creditUser} className="py-2 bg-emerald-500 hover:bg-emerald-400 text-black font-bold rounded-lg text-xs">Credit</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* INSTANCES */}
|
||||
<section className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h2 className="text-sm font-bold text-cyan-400 flex items-center gap-2"><Monitor className="w-4 h-4" /> RENTED MACHINES ({instances.length})</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs text-left text-zinc-300">
|
||||
<thead><tr className="border-b border-zinc-800 text-zinc-500 uppercase text-[10px]">
|
||||
<th className="py-2 px-3">Name</th><th className="py-2 px-3">OS</th><th className="py-2 px-3">SKU</th><th className="py-2 px-3">VMID</th><th className="py-2 px-3">Port</th><th className="py-2 px-3">State</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{instances.map((i) => (
|
||||
<tr key={i.id} className="hover:bg-zinc-900/50">
|
||||
<td className="py-2 px-3 font-bold text-cyan-300">{i.name}</td>
|
||||
<td className="py-2 px-3 text-zinc-400">{i.os}</td>
|
||||
<td className="py-2 px-3 text-amber-300">{i.sku}</td>
|
||||
<td className="py-2 px-3 font-mono text-zinc-400">{i.vm_id}</td>
|
||||
<td className="py-2 px-3 font-mono text-emerald-400">{i.port ?? '—'}</td>
|
||||
<td className="py-2 px-3"><span className={`px-2 py-0.5 rounded text-[10px] font-bold ${i.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : i.state === 'failed' ? 'bg-red-500/20 text-red-300' : 'bg-amber-500/20 text-amber-300'}`}>{i.state}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* USERS */}
|
||||
<section className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h2 className="text-sm font-bold text-cyan-400 flex items-center gap-2"><ShieldAlert className="w-4 h-4" /> USERS ({users.length})</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs text-left text-zinc-300">
|
||||
<thead><tr className="border-b border-zinc-800 text-zinc-500 uppercase text-[10px]">
|
||||
<th className="py-2 px-3">User</th><th className="py-2 px-3">Balance</th><th className="py-2 px-3">ID</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-zinc-900/50">
|
||||
<td className="py-2 px-3 font-bold text-cyan-300">{u.username}</td>
|
||||
<td className="py-2 px-3 text-amber-300">{u.balance_minutes}m</td>
|
||||
<td className="py-2 px-3 font-mono text-zinc-500">{u.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AdminApp />
|
||||
</React.StrictMode>
|
||||
);
|
||||
519
src/components/AdminDashboard.tsx
Normal file
519
src/components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
Settings,
|
||||
Server,
|
||||
Cpu,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Sliders,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Database,
|
||||
Lock,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { ProxiflyGlobalConfig, SystemNode, VM } from '../types';
|
||||
|
||||
interface AdminDashboardProps {
|
||||
isAdminAuthenticated: boolean;
|
||||
onAdminLogin: (user: string, pass: string) => boolean;
|
||||
proxiflyConfig: ProxiflyGlobalConfig;
|
||||
onUpdateProxifly: (config: Partial<ProxiflyGlobalConfig>) => void;
|
||||
nodes: SystemNode[];
|
||||
vms: VM[];
|
||||
onForceKillVm: (vmId: string) => void;
|
||||
onRotateAllProxies: () => void;
|
||||
}
|
||||
|
||||
export const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
||||
isAdminAuthenticated,
|
||||
onAdminLogin,
|
||||
proxiflyConfig,
|
||||
onUpdateProxifly,
|
||||
nodes,
|
||||
vms,
|
||||
onForceKillVm,
|
||||
onRotateAllProxies,
|
||||
}) => {
|
||||
const [loginUser, setLoginUser] = useState('drjones');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
|
||||
// Local admin tab
|
||||
const [activeTab, setActiveTab] = useState<'proxifly' | 'nodes' | 'vms' | 'gpu_scheduling' | 'powershell_agent'>('proxifly');
|
||||
const [scriptCopied, setScriptCopied] = useState(false);
|
||||
|
||||
// Scheduler knobs
|
||||
const [idleShutdownMins, setIdleShutdownMins] = useState(10);
|
||||
const [oversubscriptionFactor, setOversubscriptionFactor] = useState(4);
|
||||
const [enableMigMode, setEnableMigMode] = useState(true);
|
||||
|
||||
const psScript = `# =====================================================================
|
||||
# VORTEX_GPU PHYSICAL WINDOWS HOST NODE AGENT INSTALLER
|
||||
# Author: drjones | Target OS: Windows 11 / Windows Server 2025
|
||||
# Hardware Requirement: NVIDIA GeForce RTX 4070 / 4080 / 4080 Super
|
||||
# =====================================================================
|
||||
|
||||
Write-Host "[VortexGPU] Initializing CUDA 12.5 Host Bridge Agent..." -ForegroundColor Cyan
|
||||
|
||||
# 1. Enable Hyper-V & WSL2 Passthrough
|
||||
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All -NoRestart
|
||||
|
||||
# 2. Configure NVIDIA GPU Paravirtualization (vGPU / Time-Slice)
|
||||
$GpuName = (Get-CimInstance Win32_VideoController | Where-Object {$_.Name -like "*RTX 40*"}).Name
|
||||
Write-Host "[NVIDIA] Detected local GPU: $GpuName" -ForegroundColor Green
|
||||
|
||||
# 3. Register Node Gateway with Vortex Platform
|
||||
$GatewayUri = "http://localhost:3000/api/vms/control"
|
||||
$NodeConfig = @{
|
||||
hostname = $env:COMPUTERNAME
|
||||
gpuModel = $GpuName
|
||||
proxiflyBridge = "ENABLED"
|
||||
idleTimeoutMin = ${idleShutdownMins}
|
||||
timeSliceFactor = ${oversubscriptionFactor}
|
||||
}
|
||||
|
||||
Invoke-RestMethod -Uri $GatewayUri -Method Post -Body ($NodeConfig | ConvertTo-Json) -ContentType "application/json"
|
||||
Write-Host "[SUCCESS] Windows Host Node active. Ready for $1/hr VM rentals!" -ForegroundColor Green`;
|
||||
|
||||
const copyScript = () => {
|
||||
navigator.clipboard.writeText(psScript);
|
||||
setScriptCopied(true);
|
||||
setTimeout(() => setScriptCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleLoginSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const success = onAdminLogin(loginUser, loginPass);
|
||||
if (!success) {
|
||||
setLoginError('Invalid Administrator Credentials. Only drjones is authorized.');
|
||||
} else {
|
||||
setLoginError('');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdminAuthenticated) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto my-12 p-6 bg-zinc-950 border border-red-500/40 rounded-2xl shadow-2xl font-mono">
|
||||
<div className="flex items-center gap-3 mb-6 text-red-400">
|
||||
<ShieldAlert className="w-8 h-8 animate-pulse" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white uppercase tracking-wider">RESTRICTED ADMIN ACCESS</h2>
|
||||
<p className="text-xs text-zinc-400">User Authentication Required: "drjones"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<div className="mb-4 p-3 bg-red-950/60 border border-red-500/50 rounded-lg text-xs text-red-300">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLoginSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">ADMIN USERNAME:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
onChange={(e) => setLoginUser(e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 font-mono text-sm outline-none focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">SECURITY ACCESS PASSCODE:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginPass}
|
||||
onChange={(e) => setLoginPass(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 font-mono text-sm outline-none focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-red-600 hover:bg-red-500 text-white font-bold rounded-xl tracking-wider text-xs uppercase transition-colors shadow-lg shadow-red-600/30"
|
||||
>
|
||||
Authenticate drjones Session
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-mono text-zinc-200">
|
||||
{/* Admin Top Banner */}
|
||||
<div className="flex flex-wrap items-center justify-between p-4 bg-zinc-950 border border-red-500/40 rounded-xl shadow-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/20 border border-red-500/40 flex items-center justify-center text-red-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white tracking-wider flex items-center gap-2">
|
||||
DRJONES ROOT SYSTEM CONTROL <span className="text-[10px] bg-red-500/20 text-red-300 px-2 py-0.5 rounded border border-red-500/30 uppercase">Master Admin</span>
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Proxifly Backend Router & RTX 4080 / 4070 Multi-Tenant GPU Scheduler Engine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('proxifly')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
activeTab === 'proxifly'
|
||||
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
PROXIFLY ENGINE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('gpu_scheduling')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
activeTab === 'gpu_scheduling'
|
||||
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
GPU SCHEDULER
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('powershell_agent')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
activeTab === 'powershell_agent'
|
||||
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
WINDOWS AGENT (.PS1)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('nodes')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
activeTab === 'nodes'
|
||||
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
HOST NODES
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('vms')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
activeTab === 'vms'
|
||||
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ACTIVE VMS ({vms.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PROXIFLY BACKEND CONTROLS */}
|
||||
{activeTab === 'proxifly' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-5 bg-zinc-950 border border-emerald-500/30 rounded-xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-emerald-500/20 pb-3">
|
||||
<h3 className="text-sm font-bold text-emerald-400 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" /> PROXIFLY BACKEND CONFIGURATION
|
||||
</h3>
|
||||
<button
|
||||
onClick={onRotateAllProxies}
|
||||
className="px-2.5 py-1 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 rounded border border-emerald-500/30 text-xs transition-colors flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Force Global IP Rotation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">PROXIFLY ROUTING MODE:</label>
|
||||
<select
|
||||
value={proxiflyConfig.mode}
|
||||
onChange={(e) => onUpdateProxifly({ mode: e.target.value as any })}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none"
|
||||
>
|
||||
<option value="random_residential">Random Residential Pool (High Stealth)</option>
|
||||
<option value="datacenter_rotation">Datacenter Fast Rotation (Ultra Latency)</option>
|
||||
<option value="strict_stealth">Strict Stealth Residential (No Leak Guarantee)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">AUTO ROTATION INTERVAL (MINUTES):</label>
|
||||
<input
|
||||
type="number"
|
||||
value={proxiflyConfig.autoRotateMinutes}
|
||||
onChange={(e) => onUpdateProxifly({ autoRotateMinutes: Number(e.target.value) })}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-zinc-300">BLOCK MALICIOUS SUBNETS:</span>
|
||||
<button
|
||||
onClick={() => onUpdateProxifly({ blockMaliciousRanges: !proxiflyConfig.blockMaliciousRanges })}
|
||||
className={`px-3 py-1 rounded text-xs font-bold ${
|
||||
proxiflyConfig.blockMaliciousRanges ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40' : 'bg-red-500/20 text-red-300 border border-red-500/40'
|
||||
}`}
|
||||
>
|
||||
{proxiflyConfig.blockMaliciousRanges ? 'ACTIVE' : 'DISABLED'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-zinc-950 border border-cyan-500/30 rounded-xl space-y-4">
|
||||
<h3 className="text-sm font-bold text-cyan-400 border-b border-cyan-500/20 pb-3 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" /> PROXIFLY LIVE METRICS
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-zinc-900/80 rounded-lg border border-zinc-800">
|
||||
<div className="text-zinc-400">Total Residential Pool:</div>
|
||||
<div className="text-lg font-bold text-cyan-300">{proxiflyConfig.activePoolSize.toLocaleString()} IPs</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-zinc-900/80 rounded-lg border border-zinc-800">
|
||||
<div className="text-zinc-400">Active Proxy Tunnels:</div>
|
||||
<div className="text-lg font-bold text-emerald-400">{proxiflyConfig.activeProxiesCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-zinc-900/80 rounded-lg border border-zinc-800">
|
||||
<div className="text-zinc-400">Average Tunnel Latency:</div>
|
||||
<div className="text-lg font-bold text-amber-300">{proxiflyConfig.avgLatencyMs} ms</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-zinc-900/80 rounded-lg border border-zinc-800">
|
||||
<div className="text-zinc-400">Default Allocation:</div>
|
||||
<div className="text-xs font-bold text-emerald-300 mt-1">Randomized on every VM launch</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPU SCHEDULER CONFIGURATION */}
|
||||
{activeTab === 'gpu_scheduling' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-5 bg-zinc-950 border border-amber-500/30 rounded-xl space-y-4">
|
||||
<h3 className="text-sm font-bold text-amber-400 border-b border-amber-500/20 pb-3 flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4" /> MULTI-TENANT OVERSUBSCRIPTION KNOBS
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 text-xs">
|
||||
<div>
|
||||
<div className="flex justify-between text-zinc-300 mb-1">
|
||||
<span>MAX TENANT VGPU OVERSUBSCRIPTION:</span>
|
||||
<span className="text-cyan-400 font-bold">{oversubscriptionFactor}x Instances per 4080 GPU</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={12}
|
||||
value={oversubscriptionFactor}
|
||||
onChange={(e) => setOversubscriptionFactor(Number(e.target.value))}
|
||||
className="w-full accent-amber-500 bg-zinc-900 rounded cursor-pointer"
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-500 mt-1">
|
||||
Time-slicing multiplexing allows supporting up to {oversubscriptionFactor * 8} simultaneous active browser stream users per host node card.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-zinc-300 mb-1">
|
||||
<span>IDLE AUTO-POWER OFF TIMEOUT:</span>
|
||||
<span className="text-amber-300 font-bold">{idleShutdownMins} Minutes</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={60}
|
||||
step={1}
|
||||
value={idleShutdownMins}
|
||||
onChange={(e) => setIdleShutdownMins(Number(e.target.value))}
|
||||
className="w-full accent-amber-500 bg-zinc-900 rounded cursor-pointer"
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-500 mt-1">
|
||||
If no active browser websocket or RDP session is connected, VM powers down to save GPU power and preserve tenant $1/hr budget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-zinc-800">
|
||||
<span className="text-zinc-300">NVIDIA CUDA MIG / VGPU MODE:</span>
|
||||
<button
|
||||
onClick={() => setEnableMigMode(!enableMigMode)}
|
||||
className={`px-3 py-1 rounded text-xs font-bold ${
|
||||
enableMigMode ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40' : 'bg-red-500/20 text-red-300 border border-red-500/40'
|
||||
}`}
|
||||
>
|
||||
{enableMigMode ? 'HARDWARE OPTIX ON' : 'SOFTWARE FALLBACK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3 text-xs">
|
||||
<h3 className="text-sm font-bold text-cyan-400 border-b border-zinc-800 pb-3 flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" /> SCALING & HARDWARE ACCELERATION STATUS
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 text-zinc-300">
|
||||
<div className="flex justify-between p-2 bg-zinc-900/60 rounded">
|
||||
<span>Total Host Rig VRAM:</span>
|
||||
<span className="text-emerald-400 font-bold">128 GB GDDR6X</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 bg-zinc-900/60 rounded">
|
||||
<span>Scheduled Virtual Instances:</span>
|
||||
<span className="text-cyan-300 font-bold">{vms.length} VMs ({vms.filter(v => v.state === 'running').length} Running)</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 bg-zinc-900/60 rounded">
|
||||
<span>Average Node Power Usage:</span>
|
||||
<span className="text-amber-300 font-bold">210W / 320W TDP</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 bg-zinc-900/60 rounded">
|
||||
<span>In-Browser Stream Latency:</span>
|
||||
<span className="text-emerald-400 font-bold">< 18 ms (Sub-frame H.264 WebRTC)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WINDOWS LOCAL HOST AGENT SCRIPT GENERATOR */}
|
||||
{activeTab === 'powershell_agent' && (
|
||||
<div className="p-5 bg-zinc-950 border border-cyan-500/30 rounded-xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-cyan-500/20 pb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-cyan-300 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-emerald-400" /> SELF-HOST WINDOWS 11 NODE AGENT DEPLOYMENT
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Run this script in PowerShell (Admin) on your Windows machine with an RTX 4070 / 4080 GPU to hook your local hardware directly into the platform gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={copyScript}
|
||||
className="px-4 py-2 bg-cyan-500 hover:bg-cyan-400 text-black font-bold rounded-lg text-xs transition-colors shadow-md shadow-cyan-500/20"
|
||||
>
|
||||
{scriptCopied ? 'COPIED TO CLIPBOARD!' : 'COPY POWERSHELL SCRIPT'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className="p-4 bg-black rounded-lg border border-zinc-800 text-xs text-emerald-400 font-mono overflow-x-auto leading-relaxed">
|
||||
{psScript}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'nodes' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-zinc-950 border border-zinc-800 rounded-xl text-xs text-zinc-300 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-bold text-amber-400">VORTEX SCHEDULER ALGORITHM:</span> Dynamic Time-Slice & VRAM Oversubscription for RTX 4080 / 4070 Nodes. Supports 1,000+ concurrent tenant sessions with automatic idle suspend.
|
||||
</div>
|
||||
<span className="px-2 py-1 bg-emerald-500/20 text-emerald-300 rounded border border-emerald-500/30 text-[10px]">EFFICIENT SCALING ON</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.nodeId} className="p-4 bg-zinc-950 border border-cyan-500/20 rounded-xl space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-zinc-800 pb-2">
|
||||
<span className="font-bold text-cyan-300">{node.hostname}</span>
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/30">
|
||||
{node.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-zinc-400">
|
||||
<div>Region: <span className="text-white">{node.region}</span></div>
|
||||
<div>GPU Core: <span className="text-amber-300 font-bold">{node.gpuType}</span></div>
|
||||
<div>Physical GPUs: <span className="text-white">{node.totalGpus}</span></div>
|
||||
<div>Assigned VMs: <span className="text-cyan-400 font-bold">{node.assignedVms}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800/80 space-y-1 text-[11px]">
|
||||
<div className="flex justify-between">
|
||||
<span>CPU LOAD:</span>
|
||||
<span className="text-emerald-400 font-bold">{node.cpuUsagePct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-900 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-emerald-400 h-full" style={{ width: `${node.cpuUsagePct}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-1">
|
||||
<span>GPU VRAM UTIL:</span>
|
||||
<span className="text-amber-400 font-bold">{node.gpuUsagePct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-900 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-amber-400 h-full" style={{ width: `${node.gpuUsagePct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACTIVE VMS MANAGEMENT */}
|
||||
{activeTab === 'vms' && (
|
||||
<div className="p-4 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3">
|
||||
<h3 className="text-sm font-bold text-cyan-400">GLOBAL ACTIVE VM INSTANCES ({vms.length})</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs text-left text-zinc-300 border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-zinc-400 uppercase text-[10px]">
|
||||
<th className="py-2 px-3">VM ID / Name</th>
|
||||
<th className="py-2 px-3">Owner</th>
|
||||
<th className="py-2 px-3">OS / GPU</th>
|
||||
<th className="py-2 px-3">Proxifly IP</th>
|
||||
<th className="py-2 px-3">State</th>
|
||||
<th className="py-2 px-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{vms.map((vm) => (
|
||||
<tr key={vm.id} className="hover:bg-zinc-900/50">
|
||||
<td className="py-2.5 px-3 font-bold text-cyan-300">
|
||||
{vm.name} <span className="text-[10px] text-zinc-500">({vm.id})</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-amber-300">{vm.userId}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{vm.os.toUpperCase()} | {vm.gpuSpec}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-mono text-emerald-400">
|
||||
{vm.proxiflyIp} <span className="text-[10px] text-zinc-500">({vm.proxiflyLocation})</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${
|
||||
vm.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : 'bg-zinc-800 text-zinc-400'
|
||||
}`}>
|
||||
{vm.state.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<button
|
||||
onClick={() => onForceKillVm(vm.id)}
|
||||
className="px-2 py-1 bg-red-600/30 hover:bg-red-600 text-red-300 hover:text-white rounded text-[10px] font-bold transition-colors"
|
||||
>
|
||||
FORCE KILL
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
433
src/components/BrowserDesktopView.tsx
Normal file
433
src/components/BrowserDesktopView.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Terminal,
|
||||
Cpu,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Monitor,
|
||||
Power,
|
||||
Sparkles,
|
||||
Bot,
|
||||
Settings,
|
||||
X,
|
||||
Volume2,
|
||||
Wifi,
|
||||
Globe,
|
||||
Lock,
|
||||
Layers,
|
||||
Zap,
|
||||
EyeOff,
|
||||
Eye,
|
||||
LineChart,
|
||||
} from 'lucide-react';
|
||||
import { VM } from '../types';
|
||||
|
||||
interface BrowserDesktopViewProps {
|
||||
vm: VM;
|
||||
onCloseDesktop: () => void;
|
||||
onPowerToggle: () => void;
|
||||
}
|
||||
|
||||
export const BrowserDesktopView: React.FC<BrowserDesktopViewProps> = ({
|
||||
vm,
|
||||
onCloseDesktop,
|
||||
onPowerToggle,
|
||||
}) => {
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [isZenMode, setIsZenMode] = useState(false);
|
||||
const [showMonitorModal, setShowMonitorModal] = useState(false);
|
||||
const [activeWindow, setActiveWindow] = useState<'comfyui' | 'ollama' | 'terminal' | 'settings' | 'none'>('comfyui');
|
||||
const [promptText, setPromptText] = useState('Cyberpunk neon hacker terminal with glowing RTX 4080 GPU core, 8k resolution, flux realistic style');
|
||||
const [generatedImages, setGeneratedImages] = useState<string[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Ollama local state
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<Array<{ sender: 'user' | 'ollama'; text: string }>>([
|
||||
{ sender: 'ollama', text: 'Ollama v0.3.1 active on NVIDIA GeForce RTX 4080 Super (16GB VRAM). Model loaded: DeepSeek-R1-14B-Q4_K_M. Ask me anything.' },
|
||||
]);
|
||||
|
||||
const handleGenerateAiImage = () => {
|
||||
if (!promptText.trim()) return;
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
const newImg = `https://picsum.photos/seed/${encodeURIComponent(promptText + Date.now())}/800/600`;
|
||||
setGeneratedImages((prev) => [newImg, ...prev]);
|
||||
setGenerating(false);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const handleSendChatMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!chatInput.trim()) return;
|
||||
const userMsg = chatInput;
|
||||
setChatMessages((prev) => [...prev, { sender: 'user', text: userMsg }]);
|
||||
setChatInput('');
|
||||
|
||||
setTimeout(() => {
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
sender: 'ollama',
|
||||
text: `[DeepSeek-R1 on RTX 4080]: Processed prompt "${userMsg}" in 0.18s using CUDA acceleration. Token throughput: 84.2 t/s.`,
|
||||
},
|
||||
]);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 bg-black flex flex-col font-sans select-none overflow-hidden ${isFullScreen || isZenMode ? 'p-0' : 'p-2 md:p-4'}`}>
|
||||
{/* Outer Window Container */}
|
||||
<div className={`flex-1 flex flex-col bg-zinc-950 border overflow-hidden relative shadow-2xl ${
|
||||
isZenMode ? 'border-none rounded-none' : 'border-cyan-500/40 rounded-xl'
|
||||
}`}>
|
||||
|
||||
{/* Low-Key Top Header (Hides in Zen mode or Fullscreen) */}
|
||||
{!isFullScreen && !isZenMode && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-cyan-500/20 text-xs font-mono">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-ping" />
|
||||
<span className="font-bold text-cyan-300">
|
||||
{vm.name} // IN-BROWSER STREAM ({vm.os.toUpperCase()})
|
||||
</span>
|
||||
<span className="text-zinc-500">|</span>
|
||||
<span className="text-amber-400 flex items-center gap-1">
|
||||
<Cpu className="w-3.5 h-3.5" /> {vm.gpuSpec} PASSTHROUGH
|
||||
</span>
|
||||
<span className="text-zinc-500">|</span>
|
||||
<span className="text-emerald-400 flex items-center gap-1">
|
||||
<Globe className="w-3.5 h-3.5" /> PROXIFLY: {vm.proxiflyIp} ({vm.proxiflyLocation})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsZenMode(true)}
|
||||
className="px-2.5 py-1 bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 rounded text-[11px] transition-colors flex items-center gap-1 border border-amber-500/30 font-bold"
|
||||
>
|
||||
<EyeOff className="w-3.5 h-3.5" /> Pure Zen Mode
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[11px] transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" /> Fullscreen
|
||||
</button>
|
||||
<button
|
||||
onClick={onCloseDesktop}
|
||||
className="p-1 bg-red-600/30 hover:bg-red-600 text-red-300 hover:text-white rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ZEN MODE FLOATING STATS HUD (Shown in Zen Mode or toggleable) */}
|
||||
{isZenMode && (
|
||||
<div className="absolute top-3 right-3 z-50 flex items-center gap-3 bg-zinc-950/80 backdrop-blur-md border border-cyan-500/40 px-3 py-1.5 rounded-full text-[11px] font-mono text-zinc-200 shadow-2xl hover:bg-zinc-950 transition-all">
|
||||
<span className="flex items-center gap-1 text-emerald-400 font-bold">
|
||||
<Activity className="w-3.5 h-3.5 animate-pulse" /> 60 FPS
|
||||
</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span className="text-cyan-400 flex items-center gap-1">
|
||||
<Wifi className="w-3.5 h-3.5" /> {vm.stats.pingMs} ms
|
||||
</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span className="text-amber-400 flex items-center gap-1">
|
||||
<Cpu className="w-3.5 h-3.5" /> GPU {vm.stats.gpuLoad}%
|
||||
</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<button
|
||||
onClick={() => setShowMonitorModal(true)}
|
||||
className="text-purple-300 hover:text-white underline flex items-center gap-1"
|
||||
>
|
||||
<LineChart className="w-3.5 h-3.5" /> Stats
|
||||
</button>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<button
|
||||
onClick={() => setIsZenMode(false)}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/40 text-amber-300 rounded-full text-[10px] font-bold border border-amber-500/40 transition-colors"
|
||||
>
|
||||
Exit Zen Mode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Desktop Canvas Viewport */}
|
||||
<div className="flex-1 bg-zinc-900/90 relative overflow-hidden flex flex-col items-center justify-center">
|
||||
|
||||
{/* Desktop Wallpaper / Grid Background */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#152e3c_1px,transparent_1px)] [background-size:24px_24px] opacity-40 pointer-events-none" />
|
||||
|
||||
{/* Floating Application Windows inside Desktop Stream */}
|
||||
|
||||
{/* 1. ComfyUI AI Image Generation Suite */}
|
||||
{activeWindow === 'comfyui' && (
|
||||
<div className="w-full max-w-3xl bg-zinc-950/95 border border-purple-500/40 rounded-xl shadow-2xl p-5 font-mono z-20 space-y-4 my-auto">
|
||||
<div className="flex justify-between items-center border-b border-purple-500/20 pb-3">
|
||||
<div className="flex items-center gap-2 text-purple-400 font-bold text-sm">
|
||||
<Sparkles className="w-4 h-4" /> ComfyUI + Stable Diffusion Flux (RTX 4080 Hardware Accel)
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveWindow('none')}
|
||||
className="text-zinc-500 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">PROMPT NODE (FLUX.1 / SDXL):</label>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(e) => setPromptText(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-xs text-cyan-300 outline-none focus:border-purple-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
GPU VRAM: <span className="text-emerald-400">8.4 / 16 GB</span> | Sampler: <span className="text-purple-300">Euler A (25 Steps)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateAiImage}
|
||||
disabled={generating}
|
||||
className="px-5 py-2.5 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-bold rounded-lg text-xs flex items-center gap-2 shadow-lg shadow-purple-600/30 transition-all"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
CUDA SAMPLING...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4" /> QUEUE PROMPT (GPU RUN)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generated Images Output Stream */}
|
||||
{generatedImages.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 pt-3 border-t border-zinc-800">
|
||||
{generatedImages.map((imgUrl, i) => (
|
||||
<div key={i} className="aspect-video bg-black rounded-lg overflow-hidden border border-purple-500/30 group relative">
|
||||
<img src={imgUrl} alt="AI Gen" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-[10px] text-purple-300 font-bold p-2 text-center">
|
||||
Generated in 1.4s on RTX 4080 Super
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Ollama LLM Terminal App */}
|
||||
{activeWindow === 'ollama' && (
|
||||
<div className="w-full max-w-2xl bg-zinc-950/95 border border-emerald-500/40 rounded-xl shadow-2xl p-5 font-mono z-20 space-y-4 my-auto">
|
||||
<div className="flex justify-between items-center border-b border-emerald-500/20 pb-3">
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-bold text-sm">
|
||||
<Bot className="w-4 h-4" /> Ollama Local AI Chat (DeepSeek-R1 14B on GPU)
|
||||
</div>
|
||||
<button onClick={() => setActiveWindow('none')} className="text-zinc-500 hover:text-white">×</button>
|
||||
</div>
|
||||
|
||||
<div className="h-64 overflow-y-auto space-y-3 p-3 bg-zinc-900/80 rounded-lg border border-zinc-800 text-xs">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<div key={i} className={`p-2.5 rounded-lg ${msg.sender === 'user' ? 'bg-cyan-950/80 text-cyan-200 text-right ml-12' : 'bg-emerald-950/80 text-emerald-200 mr-12'}`}>
|
||||
<div className="text-[10px] text-zinc-500 mb-0.5">{msg.sender === 'user' ? 'You' : 'Ollama DeepSeek-R1'}</div>
|
||||
{msg.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSendChatMessage} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder="Ask local model anything..."
|
||||
className="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-white outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<button type="submit" className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-black font-bold rounded-lg text-xs">
|
||||
SEND
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Simulated Terminal App */}
|
||||
{activeWindow === 'terminal' && (
|
||||
<div className="w-full max-w-xl bg-black border border-cyan-500/40 rounded-xl shadow-2xl p-4 font-mono z-20 text-xs space-y-3 my-auto">
|
||||
<div className="flex justify-between text-cyan-400 border-b border-zinc-800 pb-2">
|
||||
<span>remote-shell@vortex-gpu-vm:~$</span>
|
||||
<button onClick={() => setActiveWindow('none')} className="text-zinc-500 hover:text-white">×</button>
|
||||
</div>
|
||||
<div className="space-y-1 text-zinc-300">
|
||||
<div className="text-emerald-400">Linux 6.8.0-40-generic x86_64</div>
|
||||
<div>System uptime: {Math.floor(vm.uptimeSeconds / 60)} minutes</div>
|
||||
<div>GPU Device: {vm.gpuSpec}</div>
|
||||
<div>Proxifly Outbound IP: <span className="text-cyan-400">{vm.proxiflyIp}</span></div>
|
||||
<div>Auto-power off status: Active (Triggers if idle for 10 min)</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* LOW-KEY MINIMALIST TOOLBAR AT BOTTOM (Hides in Zen mode unless hovered) */}
|
||||
{!isZenMode && (
|
||||
<div className="px-4 py-2 bg-zinc-950/95 border-t border-cyan-500/30 backdrop-blur-md flex items-center justify-between font-mono text-xs z-30">
|
||||
|
||||
{/* App Launcher Shortcuts */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveWindow('comfyui')}
|
||||
className={`px-3 py-1.5 rounded-lg border text-[11px] font-bold flex items-center gap-1.5 transition-all ${
|
||||
activeWindow === 'comfyui'
|
||||
? 'bg-purple-600/30 border-purple-500 text-purple-300 shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" /> ComfyUI AI
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveWindow('ollama')}
|
||||
className={`px-3 py-1.5 rounded-lg border text-[11px] font-bold flex items-center gap-1.5 transition-all ${
|
||||
activeWindow === 'ollama'
|
||||
? 'bg-emerald-600/30 border-emerald-500 text-emerald-300'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5 text-emerald-400" /> Ollama LLM
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveWindow('terminal')}
|
||||
className={`px-3 py-1.5 rounded-lg border text-[11px] font-bold flex items-center gap-1.5 transition-all ${
|
||||
activeWindow === 'terminal'
|
||||
? 'bg-cyan-600/30 border-cyan-500 text-cyan-300'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 text-cyan-400" /> Terminal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Real-time Telemetry Widget */}
|
||||
<div
|
||||
onClick={() => setShowMonitorModal(true)}
|
||||
className="hidden md:flex items-center gap-4 text-[11px] bg-zinc-900/80 hover:bg-zinc-900 px-3 py-1 rounded-lg border border-zinc-800 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-emerald-400 font-bold">
|
||||
<Activity className="w-3.5 h-3.5" /> 60 FPS
|
||||
</span>
|
||||
<span className="text-zinc-600">|</span>
|
||||
<span className="flex items-center gap-1 text-cyan-400">
|
||||
<Wifi className="w-3.5 h-3.5" /> {vm.stats.pingMs} ms
|
||||
</span>
|
||||
<span className="text-zinc-600">|</span>
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<Cpu className="w-3.5 h-3.5" /> GPU: {vm.stats.gpuLoad}% ({vm.stats.tempC}°C)
|
||||
</span>
|
||||
<span className="text-zinc-600">|</span>
|
||||
<span className="flex items-center gap-1 text-purple-400">
|
||||
<Globe className="w-3.5 h-3.5" /> IP: {vm.proxiflyIp}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Exit / Fullscreen / Zen Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsZenMode(true)}
|
||||
className="px-2.5 py-1.5 bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 rounded-lg border border-amber-500/40 text-[11px] font-bold flex items-center gap-1"
|
||||
title="Hide All UI & Show Pure Desktop"
|
||||
>
|
||||
<EyeOff className="w-3.5 h-3.5" /> Pure Zen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullScreen(!isFullScreen)}
|
||||
className="p-1.5 bg-zinc-900 hover:bg-zinc-800 text-zinc-300 rounded-lg border border-zinc-800"
|
||||
title="Toggle Fullscreen"
|
||||
>
|
||||
{isFullScreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCloseDesktop}
|
||||
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600 text-red-300 hover:text-white border border-red-500/40 rounded-lg text-[11px] font-bold transition-all"
|
||||
>
|
||||
DISCONNECT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DETAILED MONITORING METRICS MODAL */}
|
||||
{showMonitorModal && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4 font-mono">
|
||||
<div className="w-full max-w-xl bg-zinc-950 border border-cyan-500/40 rounded-2xl p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex justify-between items-center border-b border-zinc-800 pb-3">
|
||||
<div className="flex items-center gap-2 text-cyan-300 font-bold text-sm">
|
||||
<LineChart className="w-5 h-5 text-emerald-400" /> REAL-TIME VM & NETWORK MONITORING
|
||||
</div>
|
||||
<button onClick={() => setShowMonitorModal(false)} className="text-zinc-500 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-zinc-900 rounded-xl border border-zinc-800 space-y-1">
|
||||
<div className="text-zinc-400">GPU Core Clock:</div>
|
||||
<div className="text-base font-bold text-amber-300">2,610 MHz</div>
|
||||
</div>
|
||||
<div className="p-3 bg-zinc-900 rounded-xl border border-zinc-800 space-y-1">
|
||||
<div className="text-zinc-400">GPU Power Draw:</div>
|
||||
<div className="text-base font-bold text-emerald-400">195 Watts (320W Limit)</div>
|
||||
</div>
|
||||
<div className="p-3 bg-zinc-900 rounded-xl border border-zinc-800 space-y-1">
|
||||
<div className="text-zinc-400">VRAM Usage:</div>
|
||||
<div className="text-base font-bold text-purple-300">{vm.stats.vramUsedGb} / 16.0 GB GDDR6X</div>
|
||||
</div>
|
||||
<div className="p-3 bg-zinc-900 rounded-xl border border-zinc-800 space-y-1">
|
||||
<div className="text-zinc-400">Proxifly Outbound Tunnel:</div>
|
||||
<div className="text-base font-bold text-cyan-300">{vm.proxiflyIp}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-zinc-800 text-xs">
|
||||
<div className="text-zinc-400 font-bold">NETWORK THROUGHPUT GRAPH (LIVE WEBRTC STREAM):</div>
|
||||
<div className="h-16 bg-zinc-900 rounded-xl border border-zinc-800 p-2 flex items-end justify-between gap-1">
|
||||
{[40, 65, 50, 80, 95, 70, 85, 90, 60, 75, 88, 92, 70, 85, 98, 90].map((val, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex-1 bg-cyan-500/60 hover:bg-cyan-400 rounded-t transition-all"
|
||||
style={{ height: `${val}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-zinc-500">
|
||||
<span>Inbound Stream: 48 Mbps</span>
|
||||
<span>Latency Jitter: < 2 ms</span>
|
||||
<span>Enc: H.264 / AV1 HW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowMonitorModal(false)}
|
||||
className="w-full py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-bold text-xs rounded-xl transition-colors"
|
||||
>
|
||||
Close Telemetry Window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
175
src/components/BtcPayModal.tsx
Normal file
175
src/components/BtcPayModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Bitcoin, Zap, CheckCircle2, Copy, Clock, ShieldCheck, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
|
||||
export interface RealInvoice {
|
||||
invoiceId: string;
|
||||
btcpayInvoiceId: string;
|
||||
amountUsd: number;
|
||||
minutesAdded: number;
|
||||
checkoutLink: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface BtcPayModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId: string;
|
||||
onAddMinutes: (minutes: number) => void;
|
||||
}
|
||||
|
||||
export const BtcPayModal: React.FC<BtcPayModalProps> = ({ isOpen, onClose, userId, onAddMinutes }) => {
|
||||
const [selectedHours, setSelectedHours] = useState<number>(5);
|
||||
const [activeInvoice, setActiveInvoice] = useState<RealInvoice | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Poll the gateway for settlement status while an invoice is open.
|
||||
const pollSettlement = useCallback(async (invoiceId: string) => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const res = await fetch(`/api/me?userId=${userId}`);
|
||||
const data = await res.json();
|
||||
const inv = data.invoices?.find((x: any) => x.id === invoiceId);
|
||||
if (inv && inv.status === 'settled') {
|
||||
onAddMinutes(inv.minutes);
|
||||
setActiveInvoice(null);
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
}, [userId, onAddMinutes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeInvoice?.status === 'pending') {
|
||||
pollSettlement(activeInvoice.invoiceId);
|
||||
}
|
||||
}, [activeInvoice, pollSettlement]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleGenerateInvoice = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/btcpay/create-invoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, usdAmount: selectedHours }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('Invoice failed:', data.error);
|
||||
return;
|
||||
}
|
||||
setActiveInvoice(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4">
|
||||
<div className="w-full max-w-lg bg-zinc-950 border border-amber-500/40 rounded-2xl p-6 shadow-2xl relative font-sans">
|
||||
<button onClick={onClose} className="absolute top-4 right-4 text-zinc-400 hover:text-white transition-colors text-xl font-bold">×</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400">
|
||||
<Bitcoin className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-wide flex items-center gap-2">
|
||||
BTCPay Server Gateway <span className="text-xs bg-amber-500/20 text-amber-300 px-2 py-0.5 rounded border border-amber-500/30">Anonymous BTC</span>
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400">No KYC. $1 = 1 hour GPU time. Settlement credits your balance automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!activeInvoice ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-zinc-400 mb-2 uppercase tracking-wider">Select Runtime Hours ($1 USD / hour):</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[1, 5, 12, 24].map((hrs) => (
|
||||
<button
|
||||
key={hrs}
|
||||
type="button"
|
||||
onClick={() => setSelectedHours(hrs)}
|
||||
className={`p-3 rounded-xl border font-mono text-center transition-all ${
|
||||
selectedHours === hrs
|
||||
? 'bg-amber-500/20 border-amber-500 text-amber-300 shadow-[0_0_15px_rgba(245,158,11,0.2)]'
|
||||
: 'bg-zinc-900/60 border-zinc-800 text-zinc-400 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg font-bold">${hrs}</div>
|
||||
<div className="text-[10px] text-zinc-400">{hrs} Hours</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/70 p-4 rounded-xl border border-zinc-800 text-xs font-mono space-y-2">
|
||||
<div className="flex justify-between"><span className="text-zinc-400">Rate:</span><span className="text-emerald-400">$1.00 / hour</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400">Credits:</span><span className="text-amber-300">{selectedHours * 60} minutes</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-400">GPU:</span><span className="text-amber-300">RTX 4080 SUPER / 4070 — isolated instance</span></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateInvoice}
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-bold font-mono tracking-wider flex items-center justify-center gap-2 shadow-lg shadow-amber-500/20 transition-all"
|
||||
>
|
||||
{loading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <Zap className="w-5 h-5" />}
|
||||
GENERATE BTCPAY INVOICE (${selectedHours}.00)
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="p-4 bg-zinc-900/90 rounded-xl border border-amber-500/30 flex items-center justify-between font-mono">
|
||||
<div>
|
||||
<div className="text-[10px] text-zinc-400 uppercase">Total Due:</div>
|
||||
<div className="text-lg font-bold text-amber-400">${activeInvoice.amountUsd}.00 USD</div>
|
||||
<div className="text-xs text-zinc-400">Credits {activeInvoice.minutesAdded} minutes</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-400 uppercase">Status:</div>
|
||||
<div className="text-xs font-bold text-amber-400 flex items-center gap-1"><Clock className="w-3.5 h-3.5 animate-pulse" /> Awaiting payment</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 font-mono">
|
||||
<div className="text-xs text-zinc-400">Secure BTCPay Checkout:</div>
|
||||
<a
|
||||
href={activeInvoice.checkoutLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-bold text-sm tracking-wider shadow-lg shadow-amber-500/20 transition-all"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" /> OPEN BITCOIN CHECKOUT
|
||||
</a>
|
||||
<div className="flex items-center gap-2 bg-black p-2.5 rounded-lg border border-zinc-800 text-xs">
|
||||
<span className="flex-1 text-cyan-300 truncate">{activeInvoice.checkoutLink}</span>
|
||||
<button onClick={() => copyToClipboard(activeInvoice.checkoutLink)} className="px-2.5 py-1 bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 rounded border border-amber-500/30 text-[11px] transition-colors">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center gap-2 text-xs text-zinc-400">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
Payment is verified on-chain via the BTCPay webhook — your GPU balance is credited automatically once confirmed.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
145
src/components/Cyber3DCanvas.tsx
Normal file
145
src/components/Cyber3DCanvas.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface CyberCanvasProps {
|
||||
vmState: 'off' | 'booting' | 'running' | 'stopping' | 'suspended';
|
||||
gpuLoad: number;
|
||||
}
|
||||
|
||||
export const Cyber3DCanvas: React.FC<CyberCanvasProps> = ({ vmState, gpuLoad }) => {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 300;
|
||||
const height = container.clientHeight || 300;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 3, 6);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Cyber grid
|
||||
const gridHelper = new THREE.GridHelper(10, 20, 0x00ffcc, 0x112233);
|
||||
gridHelper.position.y = -1;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Cyber GPU Core Cube
|
||||
const geometry = new THREE.BoxGeometry(1.6, 1.6, 1.6);
|
||||
const wireframeGeo = new THREE.WireframeGeometry(geometry);
|
||||
|
||||
// Custom material
|
||||
let activeColor = 0x00ffcc; // default cyan
|
||||
if (vmState === 'running') activeColor = 0x00ff66; // green
|
||||
else if (vmState === 'booting') activeColor = 0xffaa00; // amber
|
||||
else if (vmState === 'off') activeColor = 0x445566; // dim dark
|
||||
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: activeColor,
|
||||
linewidth: 2,
|
||||
});
|
||||
const cubeWireframe = new THREE.LineSegments(wireframeGeo, lineMaterial);
|
||||
scene.add(cubeWireframe);
|
||||
|
||||
// Inner glowing core
|
||||
const coreGeo = new THREE.IcosahedronGeometry(0.7, 2);
|
||||
const coreMat = new THREE.MeshBasicMaterial({
|
||||
color: activeColor,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: vmState === 'off' ? 0.2 : 0.8,
|
||||
});
|
||||
const coreMesh = new THREE.Mesh(coreGeo, coreMat);
|
||||
scene.add(coreMesh);
|
||||
|
||||
// Particle ring
|
||||
const particleCount = 60;
|
||||
const particlesGeo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(particleCount * 3);
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (i / particleCount) * Math.PI * 2;
|
||||
const radius = 2.2;
|
||||
positions[i * 3] = Math.cos(angle) * radius;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.4;
|
||||
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
particlesGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const particleMat = new THREE.PointsMaterial({
|
||||
color: activeColor,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
const particleSystem = new THREE.Points(particlesGeo, particleMat);
|
||||
scene.add(particleSystem);
|
||||
|
||||
let animationFrameId: number;
|
||||
let clock = new THREE.Clock();
|
||||
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
const delta = clock.getDelta();
|
||||
const speed = vmState === 'running' ? 0.8 + (gpuLoad / 100) * 2 : 0.2;
|
||||
|
||||
cubeWireframe.rotation.x += delta * speed * 0.5;
|
||||
cubeWireframe.rotation.y += delta * speed;
|
||||
|
||||
coreMesh.rotation.y -= delta * speed * 0.8;
|
||||
particleSystem.rotation.y += delta * speed * 0.3;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
const handleResize = () => {
|
||||
if (!container) return;
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
geometry.dispose();
|
||||
wireframeGeo.dispose();
|
||||
lineMaterial.dispose();
|
||||
coreGeo.dispose();
|
||||
coreMat.dispose();
|
||||
particlesGeo.dispose();
|
||||
particleMat.dispose();
|
||||
renderer.dispose();
|
||||
};
|
||||
}, [vmState, gpuLoad]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full min-h-[220px] flex items-center justify-center bg-zinc-950/80 rounded-xl border border-cyan-500/20 overflow-hidden shadow-inner">
|
||||
<div ref={mountRef} className="w-full h-full absolute inset-0 pointer-events-none" />
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2 bg-black/60 backdrop-blur-md px-2.5 py-1 rounded border border-cyan-500/30 text-[10px] font-mono text-cyan-400">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
vmState === 'running' ? 'bg-emerald-400 animate-ping' :
|
||||
vmState === 'booting' ? 'bg-amber-400 animate-pulse' : 'bg-zinc-600'
|
||||
}`} />
|
||||
<span className="uppercase tracking-widest font-bold">{vmState}</span>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 z-10 text-[10px] font-mono text-cyan-300/80 bg-black/60 px-2 py-1 rounded border border-cyan-500/20">
|
||||
GPU LOAD: <span className="text-emerald-400 font-bold">{gpuLoad}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/components/Navbar.tsx
Normal file
102
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Clock,
|
||||
Bitcoin,
|
||||
Shield,
|
||||
UserCheck,
|
||||
Globe,
|
||||
Zap,
|
||||
Terminal,
|
||||
Activity,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { UserSession } from '../types';
|
||||
|
||||
interface NavbarProps {
|
||||
user: UserSession;
|
||||
onOpenBtcPay: () => void;
|
||||
onLogout: () => void;
|
||||
vmState: string;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({
|
||||
user,
|
||||
onOpenBtcPay,
|
||||
onLogout,
|
||||
vmState,
|
||||
}) => {
|
||||
const hours = Math.floor(user.balanceMinutes / 60);
|
||||
const mins = user.balanceMinutes % 60;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 bg-zinc-950/90 border-b border-cyan-500/30 backdrop-blur-md px-4 py-3 font-mono text-xs">
|
||||
<div className="max-w-7xl mx-auto flex flex-wrap items-center justify-between gap-4">
|
||||
|
||||
{/* Brand & GPU Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-black font-extrabold shadow-lg shadow-cyan-500/30">
|
||||
<Cpu className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-base font-black tracking-wider text-white">
|
||||
VORTEX<span className="text-cyan-400">_GPU</span>
|
||||
</h1>
|
||||
<span className="text-[10px] bg-cyan-950 text-cyan-300 px-2 py-0.5 rounded border border-cyan-500/30 font-bold">
|
||||
RTX 4080 / 4070
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400 flex items-center gap-2">
|
||||
<span className="text-emerald-400 flex items-center gap-0.5">
|
||||
<Shield className="w-3 h-3" /> NO KYC
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="text-amber-400 flex items-center gap-0.5">
|
||||
<Globe className="w-3 h-3" /> PROXIFLY BACKEND
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Live Balance Counter ($1/hr) */}
|
||||
<div className="flex items-center gap-3 bg-zinc-900/90 px-4 py-2 rounded-xl border border-amber-500/30 shadow-inner">
|
||||
<Clock className="w-4 h-4 text-amber-400 animate-pulse" />
|
||||
<div>
|
||||
<div className="text-[10px] text-zinc-400 uppercase tracking-widest">Time Remaining ($1/hr):</div>
|
||||
<div className="text-sm font-bold text-amber-300">
|
||||
{String(hours).padStart(2, '0')}h {String(mins).padStart(2, '0')}m
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenBtcPay}
|
||||
className="ml-2 px-3 py-1.5 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-bold rounded-lg text-[11px] flex items-center gap-1 shadow-md shadow-amber-500/20 transition-all"
|
||||
>
|
||||
<Bitcoin className="w-3.5 h-3.5" /> + TOP UP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Identity */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-cyan-300 font-bold flex items-center gap-1 justify-end">
|
||||
<UserCheck className="w-3.5 h-3.5" /> @{user.username}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
Anonymous Client
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-2 bg-zinc-900 hover:bg-zinc-800 text-zinc-400 hover:text-white rounded-xl border border-zinc-800 transition-colors"
|
||||
title="Sign Out / Reset Session"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
204
src/components/NeonTerminal.tsx
Normal file
204
src/components/NeonTerminal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Terminal, Shield, Cpu, RefreshCw, Send, HelpCircle, HardDrive, Zap } from 'lucide-react';
|
||||
import { VM, UserSession } from '../types';
|
||||
|
||||
interface NeonTerminalProps {
|
||||
vm: VM | null;
|
||||
user: UserSession;
|
||||
onExecuteCommand: (cmd: string) => void;
|
||||
onVmStateChange: (state: 'booting' | 'running' | 'off') => void;
|
||||
}
|
||||
|
||||
export const NeonTerminal: React.FC<NeonTerminalProps> = ({
|
||||
vm,
|
||||
user,
|
||||
onExecuteCommand,
|
||||
onVmStateChange,
|
||||
}) => {
|
||||
const [inputCmd, setInputCmd] = useState('');
|
||||
const [logs, setLogs] = useState<Array<{ id: string; text: string; type: 'cmd' | 'output' | 'system' | 'error' }>>([
|
||||
{ id: '1', text: '=== VORTEX_GPU KERNEL v4.8.1-PROXIFLY READY ===', type: 'system' },
|
||||
{ id: '2', text: `SESSION IDENT: ${user.username.toUpperCase()} | BALANCE: ${user.balanceMinutes} MINUTES`, type: 'system' },
|
||||
{ id: '3', text: 'Type "help", "status", "proxifly rotate", "apps", or "gpu-info" for available hacking controls.', type: 'system' },
|
||||
]);
|
||||
|
||||
const handleSend = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!inputCmd.trim()) return;
|
||||
|
||||
const trimmed = inputCmd.trim();
|
||||
const newLogs = [...logs, { id: String(Date.now()), text: `${user.username}@vortex-node:~$ ${trimmed}`, type: 'cmd' as const }];
|
||||
setInputCmd('');
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (lower === 'help') {
|
||||
newLogs.push({
|
||||
id: String(Date.now() + 1),
|
||||
text: `AVAILABLE COMMANDS:
|
||||
- status : Show active VM, GPU load, Proxifly IP, and RDP port
|
||||
- poweron / poweroff : Toggle physical node power
|
||||
- proxifly rotate : Force IP rotation via Proxifly residential pool
|
||||
- gpu-info : Query NVIDIA RTX 4080/4070 driver stats & CUDA status
|
||||
- apps : List deployable AI apps (ComfyUI, Ollama, Fooocus)
|
||||
- clear : Clear terminal screen history
|
||||
- rdp-setup : Re-generate auto RDP credentials & streaming token`,
|
||||
type: 'output',
|
||||
});
|
||||
} else if (lower === 'status') {
|
||||
if (!vm) {
|
||||
newLogs.push({ id: String(Date.now() + 1), text: 'NO ACTIVE VM INSTANCE DEPLOYED. Use the UI to provision a $1/hr GPU VM.', type: 'error' });
|
||||
} else {
|
||||
newLogs.push({
|
||||
id: String(Date.now() + 1),
|
||||
text: `[VM:${vm.id}] OS: ${vm.os.toUpperCase()} | STATE: ${vm.state.toUpperCase()}
|
||||
GPU: ${vm.gpuSpec} | VRAM: ${vm.stats.vramUsedGb}/${vm.stats.vramTotalGb} GB
|
||||
PROXIFLY IP: ${vm.proxiflyIp} (${vm.proxiflyLocation}) [${vm.proxiflyProtocol}]
|
||||
AUTO POWER-OFF: Enabled if disconnected for > 10 min.`,
|
||||
type: 'output',
|
||||
});
|
||||
}
|
||||
} else if (lower === 'proxifly rotate') {
|
||||
newLogs.push({
|
||||
id: String(Date.now() + 1),
|
||||
text: '[PROXIFLY] Initiating residential IP rotation sequence...',
|
||||
type: 'system',
|
||||
});
|
||||
setTimeout(() => {
|
||||
onExecuteCommand('proxifly_rotate');
|
||||
}, 500);
|
||||
} else if (lower === 'poweron') {
|
||||
onVmStateChange('booting');
|
||||
newLogs.push({ id: String(Date.now() + 1), text: '[POWER] Booting GPU host node...', type: 'system' });
|
||||
} else if (lower === 'poweroff') {
|
||||
onVmStateChange('off');
|
||||
newLogs.push({ id: String(Date.now() + 1), text: '[POWER] Shutdown signal sent to node.', type: 'system' });
|
||||
} else if (lower === 'gpu-info') {
|
||||
newLogs.push({
|
||||
id: String(Date.now() + 1),
|
||||
text: `+-----------------------------------------------------------------------------+
|
||||
| NVIDIA-SMI 555.42.02 Driver Version: 555.42.02 CUDA Version: 12.5 |
|
||||
|-------------------------------+----------------------+----------------------+
|
||||
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
|
||||
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
|
||||
|===============================+======================+======================|
|
||||
| 0 NVIDIA GeForce RTX 4080 On | 00000000:01:00.0 On | N/A |
|
||||
| 35% 58C P2 210W / 320W | 8420MiB / 16376MiB | 74% Default |
|
||||
+-------------------------------+----------------------+----------------------+`,
|
||||
type: 'output',
|
||||
});
|
||||
} else if (lower === 'clear') {
|
||||
setLogs([]);
|
||||
return;
|
||||
} else {
|
||||
newLogs.push({
|
||||
id: String(Date.now() + 1),
|
||||
text: `vortex-sh: command not found: "${trimmed}". Type "help" for syntax.`,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
setLogs(newLogs);
|
||||
onExecuteCommand(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-950 rounded-xl border border-cyan-500/30 overflow-hidden font-mono shadow-2xl">
|
||||
{/* Terminal Header Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/90 border-b border-cyan-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500/80" />
|
||||
<span className="ml-2 text-xs text-cyan-400 font-bold flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
VORTEX_NEON_SH // PROXIFLY_ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-zinc-400">
|
||||
<span className="text-emerald-400 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> NO KYC
|
||||
</span>
|
||||
<span className="text-amber-400 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> $1/HR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output Stream */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-2 text-xs leading-relaxed max-h-[260px]">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${
|
||||
log.type === 'cmd'
|
||||
? 'text-cyan-300 font-semibold'
|
||||
: log.type === 'system'
|
||||
? 'text-emerald-400/90'
|
||||
: log.type === 'error'
|
||||
? 'text-red-400 font-medium'
|
||||
: 'text-zinc-300 font-mono whitespace-pre-wrap'
|
||||
}`}
|
||||
>
|
||||
{log.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Action Chips */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900/60 border-t border-cyan-500/10 overflow-x-auto text-[11px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputCmd('status');
|
||||
handleSend();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded bg-cyan-950/60 hover:bg-cyan-900/80 text-cyan-400 border border-cyan-500/30 transition-colors"
|
||||
>
|
||||
status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputCmd('proxifly rotate');
|
||||
handleSend();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded bg-emerald-950/60 hover:bg-emerald-900/80 text-emerald-400 border border-emerald-500/30 transition-colors"
|
||||
>
|
||||
proxifly rotate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputCmd('gpu-info');
|
||||
handleSend();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded bg-amber-950/60 hover:bg-amber-900/80 text-amber-400 border border-amber-500/30 transition-colors"
|
||||
>
|
||||
gpu-info
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputCmd('help');
|
||||
handleSend();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
|
||||
>
|
||||
help
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Command Input Prompt */}
|
||||
<form onSubmit={handleSend} className="flex items-center px-3 py-2 bg-zinc-950 border-t border-cyan-500/20">
|
||||
<span className="text-emerald-400 font-bold text-xs mr-2">{user.username}@node:~$</span>
|
||||
<input
|
||||
type="text"
|
||||
value={inputCmd}
|
||||
onChange={(e) => setInputCmd(e.target.value)}
|
||||
placeholder="type command..."
|
||||
className="flex-1 bg-transparent text-cyan-300 placeholder-zinc-600 outline-none text-xs font-mono"
|
||||
/>
|
||||
<button type="submit" className="text-cyan-400 hover:text-cyan-200 transition-colors p-1">
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
323
src/components/VMManager.tsx
Normal file
323
src/components/VMManager.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Monitor,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Sparkles,
|
||||
Globe,
|
||||
Cpu,
|
||||
Shield,
|
||||
Layers,
|
||||
Plus,
|
||||
Zap,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { VM, OSName, GpuSpec } from '../types';
|
||||
import { AI_APP_TEMPLATES } from '../data/mockData';
|
||||
|
||||
interface VMManagerProps {
|
||||
vms: VM[];
|
||||
activeVmId: string | null;
|
||||
onSelectVm: (id: string) => void;
|
||||
onCreateVm: (os: OSName, gpu: GpuSpec, name: string, appTemplate: string) => void;
|
||||
onTogglePower: (vmId: string) => void;
|
||||
onRollDistro: (vmId: string, newOs: OSName) => void;
|
||||
onRotateVmProxy: (vmId: string) => void;
|
||||
onLaunchDesktop: (vm: VM) => void;
|
||||
userBalanceMinutes: number;
|
||||
}
|
||||
|
||||
export const VMManager: React.FC<VMManagerProps> = ({
|
||||
vms,
|
||||
activeVmId,
|
||||
onSelectVm,
|
||||
onCreateVm,
|
||||
onTogglePower,
|
||||
onRollDistro,
|
||||
onRotateVmProxy,
|
||||
onLaunchDesktop,
|
||||
userBalanceMinutes,
|
||||
}) => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newVmName, setNewVmName] = useState('Cyber-Workstation-01');
|
||||
const [selectedOs, setSelectedOs] = useState<OSName>('ubuntu24');
|
||||
const [selectedGpu, setSelectedGpu] = useState<GpuSpec>('RTX 4080 Super 16GB');
|
||||
const [selectedApp, setSelectedApp] = useState<string>('comfyui');
|
||||
|
||||
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onCreateVm(selectedOs, selectedGpu, newVmName, selectedApp);
|
||||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
const downloadRdpConfig = (vm: VM) => {
|
||||
const rdpContent = `full address:s:${vm.proxiflyIp}:${vm.rdpPort}
|
||||
username:s:${vm.rdpUser}
|
||||
prompt for credentials:i:1
|
||||
desktopwidth:i:1920
|
||||
desktopheight:i:1080
|
||||
screen mode id:i:2
|
||||
redirectsmartcards:i:0
|
||||
audiomode:i:0
|
||||
videoplaybackmode:i:1
|
||||
connection type:i:7
|
||||
networkautodetect:i:1
|
||||
bandwidthautodetect:i:1
|
||||
enablecredsspsupport:i:1
|
||||
authentication level:i:2`;
|
||||
|
||||
const blob = new Blob([rdpContent], { type: 'application/x-rdp' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${vm.name.toLowerCase().replace(/\s+/g, '_')}_autordp.rdp`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-mono">
|
||||
{/* Header Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-zinc-950 border border-cyan-500/30 rounded-xl shadow-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white tracking-wider flex items-center gap-2">
|
||||
YOUR GPU VIRTUAL MACHINES
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/30">
|
||||
$1.00 / HOUR
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Hardware accelerated NVIDIA RTX 4080 / 4070. Auto power-off when disconnected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-black font-bold text-xs rounded-xl flex items-center gap-2 shadow-lg shadow-cyan-500/20 transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> PROVISION NEW VM ($1/HR)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* VM Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{vms.map((vm) => {
|
||||
const isSelected = activeVmId === vm.id;
|
||||
return (
|
||||
<div
|
||||
key={vm.id}
|
||||
onClick={() => onSelectVm(vm.id)}
|
||||
className={`p-5 rounded-xl border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-zinc-950 border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.15)]'
|
||||
: 'bg-zinc-950/70 border-zinc-800 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{/* Card Top */}
|
||||
<div className="flex items-start justify-between border-b border-zinc-800/80 pb-3 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-bold text-cyan-300">{vm.name}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
|
||||
vm.state === 'running'
|
||||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
|
||||
: vm.state === 'booting'
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||
: 'bg-zinc-800 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{vm.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400 mt-1 flex items-center gap-3">
|
||||
<span className="text-amber-300 font-semibold">{vm.gpuSpec}</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{vm.os}</span>
|
||||
<span>•</span>
|
||||
<span>32GB RAM / 8 vCPU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePower(vm.id);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-colors ${
|
||||
vm.state === 'running'
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-400 hover:bg-red-500/20 hover:border-red-500 hover:text-red-400'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-emerald-400'
|
||||
}`}
|
||||
title={vm.state === 'running' ? 'Shut Down VM' : 'Power On VM'}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Specs & Proxifly Proxy info */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-xs">
|
||||
<div className="p-2.5 bg-zinc-900/60 rounded-lg border border-zinc-800/80">
|
||||
<div className="text-zinc-500 text-[10px] uppercase">Proxifly IP Routing:</div>
|
||||
<div className="text-emerald-400 font-bold truncate flex items-center gap-1 mt-0.5">
|
||||
<Globe className="w-3 h-3" /> {vm.proxiflyIp}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400">{vm.proxiflyLocation} ({vm.proxiflyProtocol})</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-zinc-900/60 rounded-lg border border-zinc-800/80">
|
||||
<div className="text-zinc-500 text-[10px] uppercase">Auto Idle Protection:</div>
|
||||
<div className="text-amber-300 font-bold flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-3 h-3" /> Auto Off if Idle
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400">Preserves $1/hr budget</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action Buttons */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-2 border-t border-zinc-800/80 text-[11px]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLaunchDesktop(vm);
|
||||
}}
|
||||
disabled={vm.state !== 'running'}
|
||||
className="py-2 px-2 bg-cyan-500 hover:bg-cyan-400 disabled:opacity-40 text-black font-bold rounded-lg flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> BROWSER STREAM
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadRdpConfig(vm);
|
||||
}}
|
||||
className="py-2 px-2 bg-zinc-900 hover:bg-zinc-800 text-zinc-200 border border-zinc-800 rounded-lg flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-cyan-400" /> AUTO RDP
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRotateVmProxy(vm.id);
|
||||
}}
|
||||
className="py-2 px-2 bg-zinc-900 hover:bg-zinc-800 text-zinc-200 border border-zinc-800 rounded-lg flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-emerald-400" /> ROTATE IP
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newOs: OSName = vm.os === 'ubuntu24' ? 'windows11' : vm.os === 'windows11' ? 'kali' : 'ubuntu24';
|
||||
onRollDistro(vm.id, newOs);
|
||||
}}
|
||||
className="py-2 px-2 bg-zinc-900 hover:bg-zinc-800 text-zinc-200 border border-zinc-800 rounded-lg flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-purple-400" /> ROLL DISTRO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* PROVISION NEW VM MODAL */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4">
|
||||
<div className="w-full max-w-xl bg-zinc-950 border border-cyan-500/40 rounded-2xl p-6 shadow-2xl relative">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="absolute top-4 right-4 text-zinc-400 hover:text-white font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-bold text-cyan-300 mb-1 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-400" /> PROVISION HARDWARE ACCELERATED VM
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400 mb-6">
|
||||
Instant launch. Flat rate $1.00 / hr. Baked-in RTX 4080 / 4070 GPU + Proxifly random IP.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreateSubmit} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">VM INSTANCE IDENTIFIER:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newVmName}
|
||||
onChange={(e) => setNewVmName(e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">OPERATING SYSTEM:</label>
|
||||
<select
|
||||
value={selectedOs}
|
||||
onChange={(e) => setSelectedOs(e.target.value as OSName)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-cyan-300 outline-none"
|
||||
>
|
||||
<option value="ubuntu24">Ubuntu 24.04 LTS Desktop (AI Ready)</option>
|
||||
<option value="windows11">Windows 11 Enterprise (RTX OptiX)</option>
|
||||
<option value="kali">Kali CyberSec Offensive Lab</option>
|
||||
<option value="arch">Arch Linux (Bleeding Edge)</option>
|
||||
<option value="debian">Debian 12 Stable Workspace</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">GPU ACCELERATION SPECS:</label>
|
||||
<select
|
||||
value={selectedGpu}
|
||||
onChange={(e) => setSelectedGpu(e.target.value as GpuSpec)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-amber-300 outline-none font-bold"
|
||||
>
|
||||
<option value="RTX 4080 Super 16GB">NVIDIA RTX 4080 Super 16GB VRAM</option>
|
||||
<option value="RTX 4070 12GB">NVIDIA RTX 4070 12GB VRAM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">PRE-INSTALLED AI / APP TEMPLATE:</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AI_APP_TEMPLATES.map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
onClick={() => setSelectedApp(tmpl.id)}
|
||||
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedApp === tmpl.id
|
||||
? 'bg-cyan-950/60 border-cyan-500 text-cyan-300'
|
||||
: 'bg-zinc-900/60 border-zinc-800 text-zinc-400 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-bold">{tmpl.name}</div>
|
||||
<div className="text-[10px] text-zinc-500 line-clamp-1">{tmpl.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-black font-bold rounded-xl text-xs uppercase tracking-wider"
|
||||
>
|
||||
LAUNCH INSTANCE ($1.00 / HOUR)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
src/index.css
Normal file
17
src/index.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #05070d;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-thumb { background: #27272a; border-radius: 4px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #3f3f46; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
::selection { background: rgba(34, 211, 238, 0.35); color: #fff; }
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
92
src/types.ts
Normal file
92
src/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type OSName = 'windows11' | 'ubuntu24' | 'kali' | 'arch' | 'debian';
|
||||
export type GpuSpec = 'RTX 4070 12GB' | 'RTX 4080 Super 16GB';
|
||||
export type VMState = 'off' | 'booting' | 'running' | 'stopping' | 'suspended';
|
||||
|
||||
export interface VMStats {
|
||||
cpuLoad: number; // percentage 0-100
|
||||
gpuLoad: number; // percentage 0-100
|
||||
vramUsedGb: number; // e.g. 8.4
|
||||
vramTotalGb: number; // e.g. 16
|
||||
tempC: number; // e.g. 58
|
||||
pingMs: number; // e.g. 14
|
||||
fps: number; // e.g. 60
|
||||
networkUpMbps: number;
|
||||
networkDownMbps: number;
|
||||
}
|
||||
|
||||
export interface VM {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
os: OSName;
|
||||
gpuSpec: GpuSpec;
|
||||
vcpu: number;
|
||||
ramGb: number;
|
||||
storageGb: number;
|
||||
state: VMState;
|
||||
proxiflyIp: string;
|
||||
proxiflyLocation: string;
|
||||
proxiflyProtocol: 'SOCKS5' | 'HTTP';
|
||||
proxiflyType: 'Residential' | 'Datacenter' | 'Mobile';
|
||||
rdpPort: number;
|
||||
rdpUser: string;
|
||||
rdpPass: string;
|
||||
autoPowerOffMin: number;
|
||||
uptimeSeconds: number;
|
||||
installedAppTemplates: string[];
|
||||
stats: VMStats;
|
||||
createdTime: number;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
balanceMinutes: number;
|
||||
btcAddress: string;
|
||||
activeVmId: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface BtcPayInvoice {
|
||||
invoiceId: string;
|
||||
amountUsd: number;
|
||||
amountSats: number;
|
||||
btcAddress: string;
|
||||
lightningInvoice: string;
|
||||
status: 'pending' | 'settled' | 'expired';
|
||||
minutesAdded: number;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ProxiflyGlobalConfig {
|
||||
mode: 'random_residential' | 'datacenter_rotation' | 'strict_stealth';
|
||||
activePoolSize: number;
|
||||
autoRotateMinutes: number;
|
||||
blockMaliciousRanges: boolean;
|
||||
activeProxiesCount: number;
|
||||
avgLatencyMs: number;
|
||||
}
|
||||
|
||||
export interface SystemNode {
|
||||
nodeId: string;
|
||||
hostname: string;
|
||||
region: string;
|
||||
gpuType: string;
|
||||
totalGpus: number;
|
||||
assignedVms: number;
|
||||
cpuUsagePct: number;
|
||||
memUsagePct: number;
|
||||
gpuUsagePct: number;
|
||||
status: 'online' | 'degraded' | 'maintenance';
|
||||
}
|
||||
|
||||
export interface AppTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: 'AI & ML' | 'OS & Desktop' | 'Security & Tools' | 'Rendering';
|
||||
gpuRequirement: string;
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'index.html'),
|
||||
admin: path.resolve(__dirname, 'admin.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
228
vortex-node-agent-linux.py
Normal file
228
vortex-node-agent-linux.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VORTEX_GPU — Linux Host Node Agent (port of vortex-node-agent.ps1)
|
||||
Target: Ubuntu (nightmare .128, RTX 4080 SUPER 16GB)
|
||||
|
||||
Responsibilities (mirrors the Windows agent):
|
||||
- Register this GPU box + stream nvidia-smi telemetry every N sec.
|
||||
- Poll the gateway for jobs:
|
||||
* shell : run an arbitrary cmd, return stdout
|
||||
* hashcat / comfyui : run the command against the local GPU
|
||||
* provision_comfyui : launch an ISOLATED ComfyUI instance on a dedicated
|
||||
port + dedicated user/output/input dirs (tenant sees
|
||||
a clean private machine; the physical GPU is shared/hidden)
|
||||
* destroy_instance : kill the ComfyUI process for an instance
|
||||
|
||||
Auth: X-Node-Secret header (matches server.ts nodeAuthorized).
|
||||
Runs as a systemd service: vortex-node-agent.service
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
GATEWAY = os.environ.get("VORTEX_GATEWAY", "http://10.30.20.127:3000")
|
||||
SECRET = os.environ.get("VORTEX_NODE_SECRET", "99496a5bf30b5a7411d3a60bf096ca642815fcf2be361113")
|
||||
INTERVAL = int(os.environ.get("VORTEX_INTERVAL", "5"))
|
||||
HOSTNAME = socket.gethostname()
|
||||
|
||||
HOME = os.path.expanduser("~")
|
||||
COMFY_DIR = os.path.join(HOME, "ComfyUI")
|
||||
COMFY_PY = os.path.join(COMFY_DIR, "venv", "bin", "python")
|
||||
INST_DIR = os.path.join(HOME, "vortex-agent", "instances")
|
||||
os.makedirs(INST_DIR, exist_ok=True)
|
||||
|
||||
_instance_pids = {} # instanceId -> pid
|
||||
|
||||
|
||||
def http(method, path, body=None):
|
||||
req = urllib.request.Request(GATEWAY + path, method=method)
|
||||
req.add_header("X-Node-Secret", SECRET)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, data=data, timeout=60) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def gpu_info():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["nvidia-smi",
|
||||
"--query-gpu=name,driver_version,memory.total,memory.used,utilization.gpu,temperature.gpu",
|
||||
"--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=10).stdout.strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not out:
|
||||
return None
|
||||
p = [x.strip() for x in out.split(",")]
|
||||
if len(p) < 6:
|
||||
return None
|
||||
try:
|
||||
return {"gpuModel": p[0], "driverVersion": p[1], "memTotalMb": int(p[2]),
|
||||
"memUsedMb": int(p[3]), "gpuUtilPct": int(p[4]), "tempC": int(p[5])}
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _procstat():
|
||||
with open("/proc/stat") as f:
|
||||
fields = f.readline().split()[1:]
|
||||
idle = int(fields[3]) + int(fields[4])
|
||||
total = sum(int(x) for x in fields)
|
||||
return idle, total
|
||||
|
||||
|
||||
def cpu_util():
|
||||
try:
|
||||
i1, t1 = _procstat()
|
||||
time.sleep(0.2)
|
||||
i2, t2 = _procstat()
|
||||
if t2 == t1:
|
||||
return 0
|
||||
return int(100 * (1 - (i2 - i1) / (t2 - t1)))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def ram_info():
|
||||
mem = {}
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
if ":" in line:
|
||||
k = line.split(":")[0]
|
||||
v = line.split(":")[1].strip().split()[0]
|
||||
mem[k] = int(v)
|
||||
total_gb = round(mem["MemTotal"] / 1024 / 1024, 1)
|
||||
avail_gb = round(mem.get("MemAvailable", 0) / 1024 / 1024, 1)
|
||||
return total_gb, round(total_gb - avail_gb, 1)
|
||||
|
||||
|
||||
def uptime_sec():
|
||||
return int(float(open("/proc/uptime").read().split()[0]))
|
||||
|
||||
|
||||
def run_shell(command):
|
||||
try:
|
||||
r = subprocess.run(["bash", "-c", command], capture_output=True, text=True, timeout=600)
|
||||
return (r.returncode == 0), (r.stdout or "") + (r.stderr or "")
|
||||
except Exception as e:
|
||||
return False, f"error: {e}"
|
||||
|
||||
|
||||
def provision_comfyui(instance_id, port, user_dir):
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
for sub in ("user", "output", "input", "models", "custom_nodes"):
|
||||
os.makedirs(os.path.join(user_dir, sub), exist_ok=True)
|
||||
if not os.path.exists(os.path.join(COMFY_DIR, "main.py")):
|
||||
return False, "ComfyUI base not found on this node"
|
||||
if not os.path.exists(COMFY_PY):
|
||||
return False, "ComfyUI venv not found"
|
||||
args = [COMFY_PY, os.path.join(COMFY_DIR, "main.py"),
|
||||
"--listen", "0.0.0.0", "--port", str(port),
|
||||
"--user-directory", os.path.join(user_dir, "user"),
|
||||
"--output-directory", os.path.join(user_dir, "output"),
|
||||
"--input-directory", os.path.join(user_dir, "input")]
|
||||
try:
|
||||
proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
_instance_pids[instance_id] = proc.pid
|
||||
with open(os.path.join(user_dir, "instance.pid"), "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
return True, f"launched pid={proc.pid} port={port}"
|
||||
except Exception as e:
|
||||
return False, f"failed to launch: {e}"
|
||||
|
||||
|
||||
def destroy_instance(instance_id):
|
||||
pid = _instance_pids.pop(instance_id, None)
|
||||
if pid is None:
|
||||
pidfile = os.path.join(INST_DIR, instance_id, "instance.pid")
|
||||
if os.path.exists(pidfile):
|
||||
try:
|
||||
pid = int(open(pidfile).read().strip())
|
||||
except Exception:
|
||||
pid = None
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, 15)
|
||||
return True, f"killed pid={pid}"
|
||||
except Exception as e:
|
||||
return True, f"kill pid={pid}: {e}"
|
||||
return True, "no pid found"
|
||||
|
||||
|
||||
def handle_job(job):
|
||||
kind = job.get("kind")
|
||||
cmd = job.get("command", "")
|
||||
payload = job.get("payload", {}) or {}
|
||||
if kind in ("shell", "hashcat", "comfyui"):
|
||||
ok, result = run_shell(cmd)
|
||||
elif kind == "provision_comfyui":
|
||||
ok, result = provision_comfyui(payload.get("instanceId", "inst"),
|
||||
payload.get("port", 8189),
|
||||
payload.get("userDir") or os.path.join(INST_DIR, payload.get("instanceId", "inst")))
|
||||
elif kind == "destroy_instance":
|
||||
ok, result = destroy_instance(payload.get("instanceId", ""))
|
||||
else:
|
||||
ok, result = False, "unknown job kind"
|
||||
try:
|
||||
http("POST", f"/api/node/jobs/{job['id']}/result", {"ok": ok, "result": result})
|
||||
except Exception as e:
|
||||
print(f"[vortex-agent] result post failed: {e}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
g = gpu_info()
|
||||
ram_t, _ = ram_info()
|
||||
print(f"[vortex-agent] registering node '{HOSTNAME}' with {GATEWAY} ...", flush=True)
|
||||
try:
|
||||
http("POST", "/api/node/register", {
|
||||
"hostname": HOSTNAME,
|
||||
"gpuModel": g["gpuModel"] if g else "GPU",
|
||||
"driverVersion": g["driverVersion"] if g else "",
|
||||
"memTotalMb": g["memTotalMb"] if g else 0,
|
||||
"ramTotalGb": ram_t,
|
||||
})
|
||||
print(f"[vortex-agent] registered. GPU: {g['gpuModel'] if g else '?'}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[vortex-agent] register failed: {e}", flush=True)
|
||||
|
||||
print("[vortex-agent] agent active.", flush=True)
|
||||
while True:
|
||||
g = gpu_info()
|
||||
ram_t, ram_u = ram_info()
|
||||
cpu = cpu_util()
|
||||
up = uptime_sec()
|
||||
try:
|
||||
http("POST", "/api/node/report", {
|
||||
"hostname": HOSTNAME,
|
||||
"gpuModel": g["gpuModel"] if g else "GPU",
|
||||
"driverVersion": g["driverVersion"] if g else "",
|
||||
"memTotalMb": g["memTotalMb"] if g else 0,
|
||||
"memUsedMb": g["memUsedMb"] if g else 0,
|
||||
"gpuUtilPct": g["gpuUtilPct"] if g else 0,
|
||||
"tempC": g["tempC"] if g else 0,
|
||||
"cpuUtilPct": cpu, "ramTotalGb": ram_t, "ramUsedGb": ram_u,
|
||||
"uptimeSec": up,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[vortex-agent] heartbeat dropped: {e}", flush=True)
|
||||
|
||||
try:
|
||||
resp = http("GET", f"/api/node/jobs?hostname={HOSTNAME}")
|
||||
for job in resp.get("jobs", []):
|
||||
print(f"[vortex-agent] job {job.get('id')} kind={job.get('kind')}", flush=True)
|
||||
handle_job(job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
241
vortex-node-agent.ps1
Normal file
241
vortex-node-agent.ps1
Normal file
@@ -0,0 +1,241 @@
|
||||
# =====================================================================
|
||||
# VORTEX_GPU — Windows Host Node Agent (v2 — provisioning-capable)
|
||||
# Author: drjones | Target: Windows 11 (RTX 4070/4080/4080S)
|
||||
#
|
||||
# Responsibilities:
|
||||
# - Register this GPU box + stream nvidia-smi telemetry every N sec.
|
||||
# - Poll the gateway for jobs:
|
||||
# * shell : run an arbitrary cmd, return stdout
|
||||
# * provision_comfyui : clone/launch an ISOLATED ComfyUI instance on a
|
||||
# dedicated port + dedicated user/output/input dir.
|
||||
# Tenant sees a clean private machine — the physical
|
||||
# GPU is shared and hidden.
|
||||
# * destroy_instance : kill the ComfyUI process for an instance.
|
||||
#
|
||||
# Isolation model: one shared ComfyUI codebase, N isolated data dirs.
|
||||
# Each instance gets its own --user-directory / --output-directory /
|
||||
# --input-directory and its own port, so settings, models, and outputs
|
||||
# never cross tenant boundaries.
|
||||
#
|
||||
# USAGE: powershell -ExecutionPolicy Bypass -File vortex-node-agent.ps1
|
||||
# =====================================================================
|
||||
|
||||
param(
|
||||
[string]$GatewayUrl = "http://10.30.20.127:3000",
|
||||
[string]$NodeSecret = "99496a5bf30b5a7411d3a60bf096ca642815fcf2be361113",
|
||||
[int]$IntervalSeconds = 5,
|
||||
[string]$PythonExe = "python"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
$HostName = $env:COMPUTERNAME
|
||||
$VORTEX_ROOT = "C:\vortex"
|
||||
$COMFY_BASE = Join-Path $VORTEX_ROOT "comfyui-base"
|
||||
$INST_DIR = Join-Path $VORTEX_ROOT "instances"
|
||||
|
||||
# Instance PID tracking: instanceId -> PID (also written to a pid file per instance)
|
||||
$script:InstancePids = @{}
|
||||
|
||||
function Get-GpuInfo {
|
||||
$csv = & nvidia-smi --query-gpu=name,driver_version,memory.total,memory.used,utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>$null
|
||||
if (-not $csv) { return $null }
|
||||
$parts = ($csv -join ",") -split ",\s*"
|
||||
return @{
|
||||
gpuModel = $parts[0].Trim()
|
||||
driverVersion = $parts[1].Trim()
|
||||
memTotalMb = [int]$parts[2]
|
||||
memUsedMb = [int]$parts[3]
|
||||
gpuUtilPct = [int]$parts[4]
|
||||
tempC = [int]$parts[5]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CpuUtil {
|
||||
$cpu = Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average
|
||||
return [int]($cpu.Average)
|
||||
}
|
||||
|
||||
function Get-RamInfo {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$totalGb = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1)
|
||||
$freeGb = [math]::Round($os.FreePhysicalMemory / 1MB, 1)
|
||||
return @{ totalGb = $totalGb; usedGb = [math]::Round($totalGb - $freeGb, 1) }
|
||||
}
|
||||
|
||||
function Invoke-Gateway {
|
||||
param($Method, $Path, $Body)
|
||||
$headers = @{ "X-Node-Secret" = $NodeSecret }
|
||||
$params = @{ Uri = "$GatewayUrl$Path"; Method = $Method; Headers = $headers; TimeoutSec = 60 }
|
||||
if ($Body) {
|
||||
$params.ContentType = "application/json"
|
||||
$params.Body = ($Body | ConvertTo-Json -Compress -Depth 10)
|
||||
}
|
||||
return Invoke-RestMethod @params
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# COMFYUI PROVISIONING
|
||||
# ---------------------------------------------------------------------
|
||||
function Ensure-ComfyBase {
|
||||
# One-time: clone ComfyUI (STABLE tag) + install torch (CUDA) into the shared base.
|
||||
# Pin to a stable release — `main` tracks bleeding-edge comfy_kitchen which
|
||||
# requires torch>=2.7. v0.9.x + torch 2.7.1+cu124 is the known-good combo.
|
||||
if (Test-Path (Join-Path $COMFY_BASE "ComfyUI\main.py")) {
|
||||
return $true
|
||||
}
|
||||
Write-Host "[VortexGPU] Bootstrapping ComfyUI base (one-time)..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $VORTEX_ROOT | Out-Null
|
||||
if (-not (Test-Path (Join-Path $COMFY_BASE "ComfyUI"))) {
|
||||
git clone --depth 1 --branch v0.9.2 https://github.com/comfyanonymous/ComfyUI.git (Join-Path $COMFY_BASE "ComfyUI") 2>&1 | Out-Null
|
||||
}
|
||||
$comfyDir = Join-Path $COMFY_BASE "ComfyUI"
|
||||
if (-not (Test-Path (Join-Path $comfyDir "main.py"))) {
|
||||
return $false
|
||||
}
|
||||
# venv + torch (CUDA 12.4) — heavy, one-time. torch 2.6.0 is the latest on
|
||||
# the cu124 index; v0.9.x works with it. Do NOT install comfy_kitchen (main-only).
|
||||
$venvPy = Join-Path $COMFY_BASE "venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $venvPy)) {
|
||||
& $PythonExe -m venv (Join-Path $COMFY_BASE "venv") 2>&1 | Out-Null
|
||||
& $venvPy -m pip install --upgrade pip 2>&1 | Out-Null
|
||||
& $venvPy -m pip install torch==2.6.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 2>&1 | Out-Null
|
||||
& $venvPy -m pip install -r (Join-Path $comfyDir "requirements.txt") 2>&1 | Out-Null
|
||||
}
|
||||
return (Test-Path $venvPy)
|
||||
}
|
||||
|
||||
function Start-ComfyInstance {
|
||||
param($instanceId, $port, $userDir)
|
||||
New-Item -ItemType Directory -Force -Path $userDir | Out-Null
|
||||
foreach ($sub in @("user", "output", "input", "models", "custom_nodes")) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $userDir $sub) | Out-Null
|
||||
}
|
||||
$comfyDir = Join-Path $COMFY_BASE "ComfyUI"
|
||||
$venvPy = Join-Path $COMFY_BASE "venv\Scripts\python.exe"
|
||||
|
||||
# Per-instance: dedicated user/output/input dirs, shared read-only base models.
|
||||
$args = @(
|
||||
(Join-Path $comfyDir "main.py"),
|
||||
"--listen", "0.0.0.0",
|
||||
"--port", $port,
|
||||
"--user-directory", (Join-Path $userDir "user"),
|
||||
"--output-directory", (Join-Path $userDir "output"),
|
||||
"--input-directory", (Join-Path $userDir "input")
|
||||
)
|
||||
$proc = Start-Process -FilePath $venvPy -ArgumentList $args -PassThru -WindowStyle Hidden
|
||||
if ($proc) {
|
||||
$script:InstancePids[$instanceId] = $proc.Id
|
||||
Set-Content -Path (Join-Path $userDir "instance.pid") -Value $proc.Id
|
||||
return "launched pid=$($proc.Id) port=$port"
|
||||
}
|
||||
return "failed to launch"
|
||||
}
|
||||
|
||||
function Stop-ComfyInstance {
|
||||
param($instanceId)
|
||||
$pidFile = Join-Path $INST_DIR $instanceId "instance.pid"
|
||||
$pid = $script:InstancePids[$instanceId]
|
||||
if (-not $pid -and (Test-Path $pidFile)) { $pid = [int](Get-Content $pidFile) }
|
||||
if ($pid) {
|
||||
Stop-Process -Id $pid -Force 2>$null
|
||||
$script:InstancePids.Remove($instanceId)
|
||||
return "killed pid=$pid"
|
||||
}
|
||||
return "no pid found"
|
||||
}
|
||||
|
||||
function Handle-Job {
|
||||
param($job)
|
||||
Write-Host "[VortexGPU] Job $($job.id) kind=$($job.kind)" -ForegroundColor Cyan
|
||||
$ok = $false
|
||||
$result = ""
|
||||
switch ($job.kind) {
|
||||
"shell" {
|
||||
$result = cmd /c $job.command 2>&1 | Out-String
|
||||
$ok = ($LASTEXITCODE -eq 0) -or ($LASTEXITCODE -eq $null)
|
||||
}
|
||||
"hashcat" {
|
||||
# hashcat job: $job.payload has target + hash; run against local GPU
|
||||
$result = cmd /c $job.command 2>&1 | Out-String
|
||||
$ok = ($LASTEXITCODE -eq 0) -or ($LASTEXITCODE -eq $null)
|
||||
}
|
||||
"comfyui" {
|
||||
$result = cmd /c $job.command 2>&1 | Out-String
|
||||
$ok = ($LASTEXITCODE -eq 0) -or ($LASTEXITCODE -eq $null)
|
||||
}
|
||||
"provision_comfyui" {
|
||||
$ok = Ensure-ComfyBase
|
||||
if ($ok) {
|
||||
$result = Start-ComfyInstance -instanceId $job.payload.instanceId -port $job.payload.port -userDir $job.payload.userDir
|
||||
$ok = $result -like "launched*"
|
||||
} else {
|
||||
$result = "ComfyUI base bootstrap failed (check git/python/torch install)"
|
||||
}
|
||||
}
|
||||
"destroy_instance" {
|
||||
$result = Stop-ComfyInstance -instanceId $job.payload.instanceId
|
||||
$ok = $true
|
||||
}
|
||||
default {
|
||||
$result = "unknown job kind"
|
||||
}
|
||||
}
|
||||
try {
|
||||
Invoke-Gateway "POST" "/api/node/jobs/$($job.id)/result" @{ ok = $ok; result = $result } | Out-Null
|
||||
} catch {
|
||||
Write-Host "[VortexGPU] result post failed: $_" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# REGISTER
|
||||
# ---------------------------------------------------------------------
|
||||
Write-Host "[VortexGPU] Registering node '$HostName' with $GatewayUrl ..." -ForegroundColor Cyan
|
||||
$gpu = Get-GpuInfo
|
||||
$ram = Get-RamInfo
|
||||
try {
|
||||
Invoke-Gateway "POST" "/api/node/register" @{
|
||||
hostname = $HostName; gpuModel = $gpu.gpuModel; driverVersion = $gpu.driverVersion
|
||||
memTotalMb = $gpu.memTotalMb; ramTotalGb = $ram.totalGb
|
||||
} | Out-Null
|
||||
Write-Host "[VortexGPU] Registered. GPU: $($gpu.gpuModel)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[VortexGPU] register failed: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# MAIN LOOP
|
||||
# ---------------------------------------------------------------------
|
||||
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
Write-Host "[VortexGPU] Agent active." -ForegroundColor Green
|
||||
|
||||
while ($true) {
|
||||
# telemetry
|
||||
$gpu = Get-GpuInfo
|
||||
$ram = Get-RamInfo
|
||||
$cpu = Get-CpuUtil
|
||||
$uptime = [int]((Get-Date) - $boot).TotalSeconds
|
||||
try {
|
||||
Invoke-Gateway "POST" "/api/node/report" @{
|
||||
hostname = $HostName; gpuModel = $gpu.gpuModel; driverVersion = $gpu.driverVersion
|
||||
memTotalMb = $gpu.memTotalMb; memUsedMb = $gpu.memUsedMb; gpuUtilPct = $gpu.gpuUtilPct
|
||||
tempC = $gpu.tempC; cpuUtilPct = $cpu; ramTotalGb = $ram.totalGb
|
||||
ramUsedGb = $ram.usedGb; uptimeSec = $uptime
|
||||
} | Out-Null
|
||||
} catch {
|
||||
Write-Host "[VortexGPU] heartbeat dropped: $_" -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
# jobs
|
||||
try {
|
||||
$resp = Invoke-Gateway "GET" "/api/node/jobs?hostname=$HostName" $null
|
||||
foreach ($job in $resp.jobs) {
|
||||
Handle-Job -job $job
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Start-Sleep -Seconds $IntervalSeconds
|
||||
}
|
||||
|
||||
# schtasks install:
|
||||
# schtasks /Create /TN "VortexGPU Node Agent" /TR "powershell -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\vortex\vortex-node-agent.ps1" /SC ONLOGON /RL HIGHEST /F
|
||||
Reference in New Issue
Block a user