feat: initialize unidesk platform

This commit is contained in:
Codex
2026-05-04 11:09:35 +00:00
commit caa80ee5e7
56 changed files with 3273 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@unidesk/components",
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"typescript": "latest",
},
},
"components/backend-core": {
"name": "@unidesk/backend-core",
"dependencies": {
"postgres": "latest",
},
},
"components/frontend": {
"name": "@unidesk/frontend",
},
"components/provider-gateway": {
"name": "@unidesk/provider-gateway",
},
"components/shared": {
"name": "@unidesk/shared",
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@unidesk/backend-core": ["@unidesk/backend-core@workspace:components/backend-core"],
"@unidesk/frontend": ["@unidesk/frontend@workspace:components/frontend"],
"@unidesk/provider-gateway": ["@unidesk/provider-gateway@workspace:components/provider-gateway"],
"@unidesk/shared": ["@unidesk/shared@workspace:components/shared"],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
+7
View File
@@ -0,0 +1,7 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/backend-core
COPY src/components/backend-core/package.json ./package.json
RUN bun install --production
COPY src/components/shared /app/src/components/shared
COPY src/components/backend-core/src ./src
CMD ["bun", "run", "src/index.ts"]
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@unidesk/backend-core",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest"
}
}
+484
View File
@@ -0,0 +1,484 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { Server, ServerWebSocket } from "bun";
import postgres from "postgres";
import {
type ApiEvent,
type ApiNode,
type ApiTask,
type CoreDispatchMessage,
type JsonValue,
type ProviderLabels,
type ProviderToCoreMessage,
isProviderToCoreMessage,
} from "../../shared/src/index";
interface RuntimeConfig {
port: number;
databaseUrl: string;
providerToken: string;
heartbeatTimeoutMs: number;
logFile: string;
}
interface WsData {
providerId?: string;
}
type ProviderSocket = ServerWebSocket<WsData>;
type SqlClient = ReturnType<typeof postgres>;
const recentLogs: unknown[] = [];
const activeProviders = new Map<string, ProviderSocket>();
const serviceStartedAt = new Date();
const config = readConfig();
const logger = createLogger("backend-core", config.logFile);
const sql = postgres(config.databaseUrl, {
max: 8,
idle_timeout: 20,
connect_timeout: 10,
});
let dbReady = false;
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
databaseUrl: requiredEnv("DATABASE_URL"),
providerToken: requiredEnv("PROVIDER_TOKEN"),
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = data === undefined
? { ts: new Date().toISOString(), service, level, message }
: { ts: new Date().toISOString(), service, level, message, data };
recentLogs.push(entry);
while (recentLogs.length > 500) recentLogs.shift();
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,POST,OPTIONS",
"access-control-allow-headers": "content-type,x-provider-token",
},
});
}
function textResponse(text: string, status = 200): Response {
return new Response(text, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
"access-control-allow-origin": "*",
},
});
}
async function initDatabase(client: SqlClient): Promise<void> {
logger("info", "database_init_start", { databaseUrl: redactDatabaseUrl(config.databaseUrl) });
await client`
CREATE TABLE IF NOT EXISTS unidesk_nodes (
provider_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL,
connected_at TIMESTAMPTZ,
last_heartbeat TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await client`
CREATE TABLE IF NOT EXISTS unidesk_events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
source TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await client`
CREATE TABLE IF NOT EXISTS unidesk_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
command TEXT NOT NULL,
status TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
dbReady = true;
logger("info", "database_init_complete");
}
async function initDatabaseWithRetry(): Promise<void> {
const started = Date.now();
let attempt = 0;
while (!dbReady) {
attempt += 1;
try {
await initDatabase(sql);
return;
} catch (error) {
const elapsedMs = Date.now() - started;
logger("warn", "database_init_retry", { attempt, elapsedMs, error: errorToJson(error) });
if (elapsedMs > 90_000) {
logger("error", "database_init_failed", { attempt, elapsedMs, error: errorToJson(error) });
throw error;
}
await Bun.sleep(Math.min(1000 * attempt, 5000));
}
}
}
function redactDatabaseUrl(value: string): string {
try {
const url = new URL(value);
if (url.password) url.password = "***";
return url.toString();
} catch {
return "<invalid-url>";
}
}
function errorToJson(error: unknown): JsonValue {
if (error instanceof Error) {
return { name: error.name, message: error.message, stack: error.stack ?? null };
}
return String(error);
}
function compactJson(value: unknown, depth = 0): JsonValue {
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "string") return value.length > 600 ? `${value.slice(0, 600)}...<truncated:${value.length}>` : value;
if (Array.isArray(value)) {
const items = value.slice(0, 20).map((item) => compactJson(item, depth + 1));
if (value.length > 20) items.push({ truncatedItems: value.length - 20 });
return items;
}
if (typeof value === "object" && value !== null) {
if (depth >= 4) return "<truncated:depth>";
const entries = Object.entries(value as Record<string, unknown>).slice(0, 30);
const result: Record<string, JsonValue> = {};
for (const [key, item] of entries) result[key] = compactJson(item, depth + 1);
const total = Object.keys(value as Record<string, unknown>).length;
if (total > entries.length) result.truncatedKeys = total - entries.length;
return result;
}
return String(value);
}
async function recordEvent(type: string, source: string, payload: JsonValue): Promise<void> {
logger("info", type, { source, payload });
if (!dbReady) return;
try {
await sql`
INSERT INTO unidesk_events (type, source, payload)
VALUES (${type}, ${source}, ${sql.json(payload)})
`;
} catch (error) {
logger("error", "event_insert_failed", { type, source, error: errorToJson(error) });
}
}
async function upsertNodeOnline(providerId: string, name: string, labels: ProviderLabels): Promise<void> {
await sql`
INSERT INTO unidesk_nodes (provider_id, name, labels, status, connected_at, last_heartbeat, updated_at)
VALUES (${providerId}, ${name}, ${sql.json(labels)}, 'online', now(), now(), now())
ON CONFLICT (provider_id) DO UPDATE SET
name = EXCLUDED.name,
labels = EXCLUDED.labels,
status = 'online',
connected_at = COALESCE(unidesk_nodes.connected_at, EXCLUDED.connected_at),
last_heartbeat = now(),
updated_at = now()
`;
}
async function touchHeartbeat(providerId: string, labels: ProviderLabels): Promise<void> {
await sql`
UPDATE unidesk_nodes
SET labels = ${sql.json(labels)}, status = 'online', last_heartbeat = now(), updated_at = now()
WHERE provider_id = ${providerId}
`;
}
async function markProviderOffline(providerId: string): Promise<void> {
activeProviders.delete(providerId);
if (!dbReady) return;
await sql`
UPDATE unidesk_nodes
SET status = 'offline', updated_at = now()
WHERE provider_id = ${providerId}
`;
await recordEvent("provider_offline", providerId, { providerId });
}
async function markStaleProvidersOffline(): Promise<void> {
if (!dbReady) return;
const timeoutMs = config.heartbeatTimeoutMs;
const rows = await sql<{ provider_id: string }[]>`
UPDATE unidesk_nodes
SET status = 'offline', updated_at = now()
WHERE status = 'online'
AND last_heartbeat IS NOT NULL
AND last_heartbeat < now() - (${timeoutMs} * interval '1 millisecond')
RETURNING provider_id
`;
for (const row of rows) {
activeProviders.delete(row.provider_id);
await recordEvent("provider_heartbeat_timeout", row.provider_id, { providerId: row.provider_id, timeoutMs });
}
}
function parseMessage(raw: string | Buffer): ProviderToCoreMessage {
const text = typeof raw === "string" ? raw : raw.toString("utf8");
const parsed = JSON.parse(text) as unknown;
if (!isProviderToCoreMessage(parsed)) {
throw new Error(`Unsupported provider message: ${text.slice(0, 200)}`);
}
return parsed;
}
async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer): Promise<void> {
const message = parseMessage(raw);
ws.data.providerId = message.providerId;
activeProviders.set(message.providerId, ws);
if (message.type === "register") {
await upsertNodeOnline(message.providerId, message.name, message.labels);
await recordEvent("provider_registered", message.providerId, {
providerId: message.providerId,
name: message.name,
labels: message.labels,
capabilities: message.capabilities,
});
ws.send(JSON.stringify({ type: "ack", requestId: "register", ok: true, message: "registered" }));
return;
}
if (message.type === "heartbeat") {
await touchHeartbeat(message.providerId, message.labels);
logger("debug", "provider_heartbeat", { providerId: message.providerId, labels: message.labels });
return;
}
await sql`
UPDATE unidesk_tasks
SET status = ${message.status}, result = ${sql.json(message.result ?? { message: message.message })}, updated_at = now()
WHERE id = ${message.taskId}
`;
await recordEvent("task_status", message.providerId, {
providerId: message.providerId,
taskId: message.taskId,
status: message.status,
message: message.message,
result: message.result ?? null,
});
}
async function getNodes(): Promise<ApiNode[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT provider_id, name, labels, status, connected_at, last_heartbeat
FROM unidesk_nodes
ORDER BY status DESC, provider_id ASC
`;
return rows.map((row) => ({
providerId: String(row.provider_id),
name: String(row.name),
status: row.status === "online" ? "online" : "offline",
labels: (row.labels ?? {}) as ProviderLabels,
connectedAt: row.connected_at instanceof Date ? row.connected_at.toISOString() : row.connected_at === null ? null : String(row.connected_at),
lastHeartbeat: row.last_heartbeat instanceof Date ? row.last_heartbeat.toISOString() : row.last_heartbeat === null ? null : String(row.last_heartbeat),
}));
}
async function getEvents(limit: number): Promise<ApiEvent[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT id, type, source, payload, created_at
FROM unidesk_events
ORDER BY id DESC
LIMIT ${limit}
`;
return rows.map((row) => ({
id: Number(row.id),
type: String(row.type),
source: String(row.source),
payload: compactJson(row.payload ?? {}),
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
}));
}
async function getTasks(limit: number): Promise<ApiTask[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT id, provider_id, command, status, payload, result, created_at, updated_at
FROM unidesk_tasks
ORDER BY updated_at DESC
LIMIT ${limit}
`;
return rows.map((row) => ({
id: String(row.id),
providerId: String(row.provider_id),
command: String(row.command),
status: String(row.status),
payload: compactJson(row.payload ?? {}),
result: compactJson(row.result ?? null),
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
}));
}
async function getOverview(): Promise<JsonValue> {
const nodes = await getNodes();
const tasks = await getTasks(50);
const online = nodes.filter((node) => node.status === "online").length;
const pendingTasks = tasks.filter((task) => task.status === "queued" || task.status === "dispatched" || task.status === "running").length;
return {
service: "unidesk-core",
ok: true,
dbReady,
uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000),
nodeCount: nodes.length,
onlineNodeCount: online,
pendingTaskCount: pendingTasks,
activeSocketCount: activeProviders.size,
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
};
}
async function dispatchTask(req: Request): Promise<Response> {
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
const providerId = typeof body.providerId === "string" ? body.providerId : "";
const command = body.command === "docker.ps" || body.command === "echo" ? body.command : "echo";
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
if (!providerId) {
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
}
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await sql`
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
VALUES (${taskId}, ${providerId}, ${command}, 'queued', ${sql.json(payload)}, NULL)
`;
const socket = activeProviders.get(providerId);
if (!socket) {
await recordEvent("task_queued_provider_offline", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "queued", providerOnline: false });
}
const dispatch: CoreDispatchMessage = { type: "dispatch", taskId, command, payload };
socket.send(JSON.stringify(dispatch));
await sql`UPDATE unidesk_tasks SET status = 'dispatched', updated_at = now() WHERE id = ${taskId}`;
await recordEvent("task_dispatched", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
}
async function route(req: Request, server: Server<WsData>): Promise<Response | undefined> {
const url = new URL(req.url);
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
if (url.pathname === "/ws/provider") {
const token = url.searchParams.get("token") ?? req.headers.get("x-provider-token");
if (token !== config.providerToken) {
await recordEvent("provider_auth_failed", "unknown", { remote: req.headers.get("x-forwarded-for") ?? null });
return jsonResponse({ ok: false, error: "invalid provider token" }, 401);
}
const upgraded = server.upgrade(req, { data: {} satisfies WsData });
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
}
try {
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-core", dbReady, startedAt: serviceStartedAt.toISOString() });
}
if (!dbReady && url.pathname.startsWith("/api/")) {
return jsonResponse({ ok: false, error: "database not ready" }, 503);
}
if (url.pathname === "/api/overview") return jsonResponse(await getOverview());
if (url.pathname === "/api/nodes") return jsonResponse({ ok: true, nodes: await getNodes() });
if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await getEvents(readLimit(url, 100)) });
if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100)) });
if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req);
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
if (url.pathname === "/favicon.ico") return textResponse("", 204);
return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404);
} catch (error) {
logger("error", "request_failed", { path: url.pathname, error: errorToJson(error) });
return jsonResponse({ ok: false, error: errorToJson(error) }, 500);
}
}
function readLimit(url: URL, defaultLimit: number): number {
const raw = url.searchParams.get("limit");
if (raw === null) return defaultLimit;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) return defaultLimit;
return Math.min(parsed, 500);
}
await initDatabaseWithRetry();
const server = Bun.serve<WsData>({
port: config.port,
hostname: "0.0.0.0",
fetch: route,
websocket: {
open(ws) {
logger("info", "provider_socket_open", { remoteAddress: ws.remoteAddress });
},
message(ws, raw) {
handleProviderMessage(ws, raw).catch((error) => {
logger("error", "provider_message_failed", { providerId: ws.data.providerId ?? null, error: errorToJson(error) });
ws.send(JSON.stringify({ type: "ack", requestId: "message", ok: false, message: String(error) }));
});
},
close(ws) {
const providerId = ws.data.providerId;
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
if (providerId !== undefined) {
markProviderOffline(providerId).catch((error) => logger("error", "provider_offline_mark_failed", { providerId, error: errorToJson(error) }));
}
},
},
});
setInterval(() => {
markStaleProvidersOffline().catch((error) => logger("error", "heartbeat_sweep_failed", { error: errorToJson(error) }));
}, 10_000);
logger("info", "server_listening", { url: `http://0.0.0.0:${server.port}`, logFile: config.logFile });
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}
@@ -0,0 +1,6 @@
# UniDesk keeps PostgreSQL mostly stock; runtime logging options are injected by docker-compose.yml.
shared_buffers = '256MB'
work_mem = '16MB'
maintenance_work_mem = '64MB'
max_connections = 100
listen_addresses = '*'
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS unidesk_nodes (
provider_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL,
connected_at TIMESTAMPTZ,
last_heartbeat TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS unidesk_events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
source TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS unidesk_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
command TEXT NOT NULL,
status TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_unidesk_nodes_status ON unidesk_nodes(status);
CREATE INDEX IF NOT EXISTS idx_unidesk_events_created_at ON unidesk_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_provider_status ON unidesk_tasks(provider_id, status);
+7
View File
@@ -0,0 +1,7 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/frontend
COPY src/components/frontend/package.json ./package.json
RUN bun install --production
COPY src/components/frontend/src ./src
COPY src/components/frontend/public ./public
CMD ["bun", "run", "src/index.ts"]
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@unidesk/frontend",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
}
}
+153
View File
@@ -0,0 +1,153 @@
const rawCfg = window.UNIDESK_CONFIG || { coreApiUrl: "http://127.0.0.1:18080", corePort: "18080" };
const cfg = { ...rawCfg, coreApiUrl: resolveCoreApiUrl(rawCfg) };
const state = { overview: null, nodes: [], events: [], tasks: [], activeModule: "ops", activeTab: "overview", lastRefresh: null };
const $ = (id) => document.getElementById(id);
const fmtTime = (value) => value ? new Date(value).toLocaleTimeString() : "--";
const compactJson = (value) => JSON.stringify(value ?? {}, null, 0);
function resolveCoreApiUrl(config) {
const api = new URL(config.coreApiUrl, window.location.href);
const pageHost = window.location.hostname;
const apiIsLoopback = api.hostname === "127.0.0.1" || api.hostname === "localhost";
const pageIsLoopback = pageHost === "127.0.0.1" || pageHost === "localhost";
if (apiIsLoopback && !pageIsLoopback) {
api.hostname = pageHost;
api.port = String(config.corePort || api.port || "18080");
}
return api.origin;
}
function setConnection(ok, text) {
const dot = $("conn-dot");
dot.className = `dot ${ok ? "ok" : "fail"}`;
$("conn-text").textContent = text;
}
async function api(path, options) {
const res = await fetch(`${cfg.coreApiUrl}${path}`, options);
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(json.error?.message || json.error || `HTTP ${res.status}`);
return json;
}
function metric(label, value, hint) {
return `<article class="metric"><div class="label">${label}</div><div class="value">${value}</div><div class="hint">${hint}</div></article>`;
}
function renderMetrics() {
const o = state.overview || {};
$("metric-grid").innerHTML = [
metric("DB Ready", o.dbReady ? "YES" : "NO", "PostgreSQL central state"),
metric("Online Nodes", o.onlineNodeCount ?? 0, `${o.nodeCount ?? 0} registered`),
metric("Active Sockets", o.activeSocketCount ?? 0, "Provider WebSocket"),
metric("Pending Tasks", o.pendingTaskCount ?? 0, "queued / running"),
].join("");
$("refresh-age").textContent = state.lastRefresh ? `刷新 ${fmtTime(state.lastRefresh)}` : "--";
}
function renderNodes() {
$("node-count").textContent = `${state.nodes.length} nodes`;
$("nodes-body").innerHTML = state.nodes.map((node) => `
<tr>
<td><span class="badge ${node.status}">${node.status}</span></td>
<td><div>${node.name}</div><div class="code">${node.providerId}</div></td>
<td class="code">${compactJson(node.labels)}</td>
<td>${fmtTime(node.lastHeartbeat)}</td>
</tr>
`).join("") || `<tr><td colspan="4">暂无 Provider 节点</td></tr>`;
if (state.nodes[0] && !$("dispatch-provider").value) $("dispatch-provider").value = state.nodes[0].providerId;
}
function renderEvents() {
$("events-body").innerHTML = state.events.map((event) => `
<tr>
<td class="code">${event.id}</td>
<td>${event.type}</td>
<td class="code">${event.source}</td>
<td class="code">${compactJson(event.payload)}</td>
<td>${fmtTime(event.createdAt)}</td>
</tr>
`).join("") || `<tr><td colspan="5">暂无事件</td></tr>`;
}
function render() {
renderMetrics();
renderNodes();
renderEvents();
document.querySelectorAll("[data-panel]").forEach((panel) => {
const key = panel.getAttribute("data-panel");
panel.style.display = state.activeTab === "overview" ? "" : key === state.activeTab ? "" : key === "overview" && state.activeModule === "ops" ? "" : "none";
});
}
async function refresh() {
try {
const [overview, nodes, events] = await Promise.all([
api("/api/overview"),
api("/api/nodes"),
api("/api/events?limit=100"),
]);
state.overview = overview;
state.nodes = nodes.nodes || [];
state.events = events.events || [];
state.lastRefresh = new Date();
setConnection(true, "核心在线");
render();
} catch (error) {
setConnection(false, error.message);
}
}
function bindNav() {
document.querySelectorAll(".module").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".module").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
state.activeModule = btn.dataset.module;
if (state.activeModule === "nodes") setTab("nodes");
if (state.activeModule === "tasks") setTab("dispatch");
if (state.activeModule === "config") setTab("events");
if (state.activeModule === "ops") setTab("overview");
});
});
document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => setTab(btn.dataset.tab)));
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
render();
}
function bindDispatch() {
$("dispatch-form").addEventListener("submit", async (event) => {
event.preventDefault();
const providerId = $("dispatch-provider").value.trim();
const command = $("dispatch-command").value;
const payloadText = $("dispatch-payload").value.trim();
try {
const payload = payloadText ? JSON.parse(payloadText) : {};
const result = await api("/api/dispatch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId, command, payload }),
});
$("dispatch-result").textContent = JSON.stringify(result, null, 2);
await refresh();
} catch (error) {
$("dispatch-result").textContent = `ERROR: ${error.message}`;
}
});
}
function tick() {
$("clock").textContent = new Date().toLocaleTimeString();
}
bindNav();
bindDispatch();
tick();
setInterval(tick, 1000);
refresh();
setInterval(refresh, 5000);
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UniDesk Control Plane</title>
<link rel="stylesheet" href="/style.css" />
<script>window.UNIDESK_CONFIG = __UNIDESK_CONFIG__;</script>
</head>
<body>
<div class="shell">
<aside class="rail" aria-label="Main modules">
<div class="brand">
<span class="brand-mark">UD</span>
<span class="brand-text">UniDesk</span>
</div>
<button class="module active" data-module="ops">运行总览</button>
<button class="module" data-module="nodes">资源节点</button>
<button class="module" data-module="tasks">任务调度</button>
<button class="module" data-module="config">系统配置</button>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">Distributed Work Platform</p>
<h1>控制平面</h1>
</div>
<div class="status-strip">
<span id="conn-dot" class="dot warn"></span>
<span id="conn-text">连接中</span>
<span id="clock">--:--:--</span>
</div>
</header>
<nav class="tabs" aria-label="Sub modules">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="nodes">Live Nodes</button>
<button class="tab" data-tab="events">Event Log</button>
<button class="tab" data-tab="dispatch">Dispatch</button>
</nav>
<section class="content-grid">
<section class="panel metrics-panel" data-panel="overview">
<div class="panel-head"><h2>核心指标</h2><span id="refresh-age">--</span></div>
<div class="metric-grid" id="metric-grid"></div>
</section>
<section class="panel table-panel" data-panel="nodes">
<div class="panel-head"><h2>Provider 节点</h2><span id="node-count">0</span></div>
<div class="table-wrap"><table><thead><tr><th>状态</th><th>Provider</th><th>标签</th><th>最后心跳</th></tr></thead><tbody id="nodes-body"></tbody></table></div>
</section>
<section class="panel table-panel" data-panel="events">
<div class="panel-head"><h2>事件流</h2><span>最近 100 条</span></div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>类型</th><th>来源</th><th>载荷</th><th>时间</th></tr></thead><tbody id="events-body"></tbody></table></div>
</section>
<section class="panel dispatch-panel" data-panel="dispatch">
<div class="panel-head"><h2>调度试运行</h2><span>真实 WebSocket 下发</span></div>
<form id="dispatch-form" class="dispatch-form">
<label>Provider ID<input id="dispatch-provider" placeholder="main-server" /></label>
<label>Command<select id="dispatch-command"><option value="docker.ps">docker.ps</option><option value="echo">echo</option></select></label>
<label class="wide">Payload JSON<textarea id="dispatch-payload">{"source":"frontend"}</textarea></label>
<button type="submit">下发任务</button>
</form>
<pre id="dispatch-result" class="result-block">等待操作</pre>
</section>
</section>
</main>
</div>
<script src="/app.js" defer></script>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
:root {
--bg: #111820;
--panel: #17212b;
--panel-2: #1d2a35;
--line: #30404d;
--line-soft: #24323e;
--text: #dce7ec;
--muted: #8496a3;
--accent: #e2a329;
--accent-2: #4bb7aa;
--danger: #d86b55;
--ok: #6fbe73;
--rail: #0c1218;
--shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
font-family: "DIN Condensed", "Aptos Narrow", "Liberation Sans Narrow", "Noto Sans", sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background:
linear-gradient(135deg, rgba(226, 163, 41, 0.08), transparent 28%),
linear-gradient(315deg, rgba(75, 183, 170, 0.08), transparent 30%),
repeating-linear-gradient(90deg, rgba(255,255,255,0.025) 0, rgba(255,255,255,0.025) 1px, transparent 1px, transparent 36px),
var(--bg);
font-size: 13px;
letter-spacing: 0.01em;
}
button, input, select, textarea {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
min-height: 100vh;
}
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 12px 10px;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, #0a1015, var(--rail));
}
.brand {
display: flex;
align-items: center;
gap: 9px;
height: 40px;
padding: 0 5px 12px;
border-bottom: 1px solid var(--line-soft);
margin-bottom: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 26px;
border: 1px solid var(--accent);
color: var(--accent);
font-weight: 800;
letter-spacing: 0.08em;
}
.brand-text {
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.module, .tab, .dispatch-form button {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
cursor: pointer;
}
.module {
display: block;
width: 100%;
padding: 8px 10px;
margin: 4px 0;
text-align: left;
border-left: 3px solid transparent;
}
.module:hover, .module.active {
color: var(--text);
background: rgba(255,255,255,0.045);
border-left-color: var(--accent);
}
.workspace {
min-width: 0;
padding: 12px 14px 16px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
min-height: 54px;
padding: 0 0 10px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
margin: 0 0 2px;
color: var(--accent);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
h1, h2 { margin: 0; font-weight: 650; }
h1 { font-size: 22px; letter-spacing: 0.08em; }
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.09em; }
.status-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 1px solid var(--line);
background: rgba(0,0,0,0.14);
color: var(--muted);
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 2px rgba(255,255,255,0.06);
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--accent); }
.dot.fail { background: var(--danger); }
.tabs {
display: flex;
gap: 6px;
padding: 10px 0;
overflow-x: auto;
}
.tab {
padding: 7px 12px;
border-color: var(--line);
background: rgba(12, 18, 24, 0.58);
color: var(--muted);
min-width: 108px;
}
.tab.active, .tab:hover {
color: var(--text);
border-color: var(--accent-2);
background: rgba(75, 183, 170, 0.12);
}
.content-grid {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(520px, 1.6fr);
gap: 10px;
align-items: stretch;
}
.panel {
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.015)), var(--panel);
box-shadow: var(--shadow);
min-width: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 10px;
border-bottom: 1px solid var(--line);
color: var(--muted);
}
.metrics-panel { grid-column: 1 / 2; }
.table-panel[data-panel="nodes"] { grid-column: 2 / 3; }
.table-panel[data-panel="events"], .dispatch-panel { grid-column: 1 / -1; }
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 10px;
}
.metric {
padding: 10px;
min-height: 74px;
border: 1px solid var(--line-soft);
background: var(--panel-2);
}
.metric .label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric .value {
margin-top: 8px;
color: var(--text);
font-size: 24px;
font-weight: 720;
}
.metric .hint {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.table-wrap { overflow: auto; max-height: 46vh; }
table { width: 100%; border-collapse: collapse; min-width: 680px; }
th, td {
padding: 7px 9px;
border-bottom: 1px solid var(--line-soft);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #121b24;
color: var(--accent);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.11em;
z-index: 1;
}
td { color: var(--text); }
.code, code {
font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace;
font-size: 12px;
color: #bfd7dc;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border: 1px solid var(--line);
color: var(--muted);
background: rgba(0,0,0,0.18);
}
.badge.online { color: var(--ok); border-color: rgba(111, 190, 115, 0.45); }
.badge.offline { color: var(--danger); border-color: rgba(216, 107, 85, 0.45); }
.dispatch-panel { min-height: 230px; }
.dispatch-form {
display: grid;
grid-template-columns: 1fr 180px auto;
gap: 8px;
padding: 10px;
align-items: end;
}
.dispatch-form label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.dispatch-form .wide { grid-column: 1 / -1; }
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
color: var(--text);
background: #0d151c;
padding: 7px 8px;
outline: none;
}
textarea { min-height: 76px; resize: vertical; font-family: "Cascadia Mono", "IBM Plex Mono", monospace; }
.dispatch-form button {
height: 33px;
padding: 0 14px;
border-color: var(--accent);
color: #130f08;
background: var(--accent);
font-weight: 700;
}
.result-block {
margin: 0 10px 10px;
padding: 8px;
max-height: 170px;
overflow: auto;
border: 1px solid var(--line-soft);
background: #0d151c;
color: #bfd7dc;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.rail {
position: static;
height: auto;
display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.brand { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; flex: 0 0 auto; }
.module { width: auto; white-space: nowrap; border-left: 0; border-bottom: 2px solid transparent; }
.module.active, .module:hover { border-bottom-color: var(--accent); }
.content-grid { grid-template-columns: 1fr; }
.metrics-panel, .table-panel[data-panel="nodes"], .table-panel[data-panel="events"], .dispatch-panel { grid-column: 1; }
.dispatch-form { grid-template-columns: 1fr; }
}
+98
View File
@@ -0,0 +1,98 @@
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
interface RuntimeConfig {
port: number;
corePublicUrl: string;
logFile: string;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
const config = readConfig();
const logger = createLogger("frontend", config.logFile);
const publicDir = join(import.meta.dir, "..", "public");
const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace(
"__UNIDESK_CONFIG__",
JSON.stringify({ coreApiUrl: config.corePublicUrl, corePort: new URL(config.corePublicUrl).port }),
);
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
corePublicUrl: requiredEnv("CORE_PUBLIC_URL"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = { ts: new Date().toISOString(), service, level, message, data };
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function contentType(pathname: string): string {
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
if (pathname.endsWith(".svg")) return "image/svg+xml";
return "text/plain; charset=utf-8";
}
function jsonResponse(body: JsonValue, status = 200): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
const server = Bun.serve({
port: config.port,
hostname: "0.0.0.0",
async fetch(req) {
const url = new URL(req.url);
logger("debug", "request", { path: url.pathname });
if (url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-frontend", coreApiUrl: config.corePublicUrl });
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } });
}
const safePath = url.pathname.replace(/^\/+/, "");
if (safePath.includes("..")) {
return jsonResponse({ ok: false, error: "invalid path" }, 400);
}
const file = Bun.file(join(publicDir, safePath));
if (!(await file.exists())) {
return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404);
}
return new Response(file, { headers: { "content-type": contentType(url.pathname) } });
},
});
logger("info", "server_listening", { url: `http://0.0.0.0:${server.port}`, coreApiUrl: config.corePublicUrl, logFile: config.logFile });
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,3 @@
FROM oven/bun:1-alpine
WORKDIR /app
CMD ["bun", "--version"]
@@ -0,0 +1,5 @@
{
"name": "@unidesk/example-service",
"private": true,
"type": "module"
}
@@ -0,0 +1 @@
export const reserved = true;
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,10 @@
FROM oven/bun:1-alpine
RUN apk add --no-cache bash docker-cli openssh-client
WORKDIR /app/src/components/provider-gateway
COPY src/components/provider-gateway/package.json ./package.json
RUN bun install --production
COPY src/components/shared /app/src/components/shared
COPY src/components/provider-gateway/src ./src
COPY src/components/provider-gateway/scripts/host-ssh-shell.sh /usr/local/bin/unidesk-host-ssh-shell
RUN chmod +x /usr/local/bin/unidesk-host-ssh-shell
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,9 @@
{
"name": "@unidesk/provider-gateway",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
host="${HOST_SSH_HOST}"
port="${HOST_SSH_PORT}"
user="${HOST_SSH_USER}"
key="${HOST_SSH_KEY}"
fallback_cwd="${HOST_REMOTE_CWD}"
requested_cwd="${UNIDESK_REQUESTED_CWD:-$fallback_cwd}"
login_shell="${HOST_LOGIN_SHELL}"
quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\''/g")"
}
q_requested=$(quote "$requested_cwd")
q_fallback=$(quote "$fallback_cwd")
q_shell=$(quote "$login_shell")
remote_cmd="cd $q_requested 2>/dev/null || cd $q_fallback 2>/dev/null || cd; export UNIDESK_BRIDGE=host-ssh; exec $q_shell -l"
exec ssh -tt \
-i "$key" \
-p "$port" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile=/tmp/unidesk-host-known-hosts \
-o ServerAliveInterval=20 \
-o ServerAliveCountMax=3 \
"$user@$host" "$remote_cmd"
@@ -0,0 +1,231 @@
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import {
type CoreDispatchMessage,
type JsonValue,
type ProviderLabels,
type ProviderTaskStatusMessage,
parseJsonObject,
} from "../../shared/src/index";
interface RuntimeConfig {
serverUrl: string;
token: string;
providerId: string;
providerName: string;
labels: ProviderLabels;
heartbeatIntervalMs: number;
reconnectBaseMs: number;
reconnectMaxMs: number;
dockerSocketPath: string;
logFile: string;
}
const startedAt = new Date();
const config = readConfig();
const logger = createLogger("provider-gateway", config.logFile);
let socket: WebSocket | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let reconnectAttempt = 0;
let stopping = false;
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
serverUrl: requiredEnv("PROVIDER_SERVER_URL"),
token: requiredEnv("PROVIDER_TOKEN"),
providerId: requiredEnv("PROVIDER_ID"),
providerName: requiredEnv("PROVIDER_NAME"),
labels: parseJsonObject(requiredEnv("PROVIDER_LABELS_JSON"), "PROVIDER_LABELS_JSON"),
heartbeatIntervalMs: readNumberEnv("HEARTBEAT_INTERVAL_MS"),
reconnectBaseMs: readNumberEnv("RECONNECT_BASE_MS"),
reconnectMaxMs: readNumberEnv("RECONNECT_MAX_MS"),
dockerSocketPath: requiredEnv("DOCKER_SOCKET_PATH"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = data === undefined
? { ts: new Date().toISOString(), service, level, message }
: { ts: new Date().toISOString(), service, level, message, data };
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function withToken(rawUrl: string, token: string): string {
const url = new URL(rawUrl);
url.searchParams.set("token", token);
return url.toString();
}
function currentLabels(): ProviderLabels {
return {
...config.labels,
dockerSocketPresent: existsSync(config.dockerSocketPath),
runtime: "bun",
gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000),
};
}
function sendJson(value: unknown): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(value));
}
function sendRegister(): void {
sendJson({
type: "register",
providerId: config.providerId,
name: config.providerName,
labels: currentLabels(),
startedAt: startedAt.toISOString(),
capabilities: ["heartbeat", "docker.ps", "echo"],
});
}
function sendHeartbeat(): void {
sendJson({
type: "heartbeat",
providerId: config.providerId,
labels: currentLabels(),
at: new Date().toISOString(),
});
}
async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage["status"], message: string, result?: JsonValue): Promise<void> {
sendJson({
type: "task_status",
providerId: config.providerId,
taskId,
status,
message,
at: new Date().toISOString(),
result,
});
}
async function runDockerPs(): Promise<JsonValue> {
const proc = Bun.spawn(["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], {
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => proc.kill("SIGKILL"), 5000);
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
clearTimeout(timeout);
if (exitCode !== 0) {
throw new Error(`docker ps failed with exit ${exitCode}: ${stderr}`);
}
const containers = stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.slice(0, 50)
.map((line) => {
const [id, name, image, status, ports] = line.split("\t");
return { id, name, image, status, ports };
});
return { containers, count: containers.length, stderr };
}
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
try {
await sendTaskStatus(message.taskId, "running", "provider running task");
if (message.command === "docker.ps") {
const result = await runDockerPs();
await sendTaskStatus(message.taskId, "succeeded", "docker ps completed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
} catch (error) {
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger("error", "dispatch_failed", { taskId: message.taskId, error: text });
await sendTaskStatus(message.taskId, "failed", text, { error: text });
}
}
function handleMessage(raw: MessageEvent<string>): void {
try {
const parsed = JSON.parse(raw.data) as { type?: unknown };
if (parsed.type === "dispatch") {
handleDispatch(parsed as CoreDispatchMessage).catch((error) => logger("error", "dispatch_handler_failed", { error: String(error) }));
return;
}
logger("debug", "core_message", parsed as JsonValue);
} catch (error) {
logger("error", "core_message_parse_failed", { error: String(error) });
}
}
function scheduleReconnect(): void {
if (stopping) return;
reconnectAttempt += 1;
const delayMs = Math.min(config.reconnectMaxMs, config.reconnectBaseMs * 2 ** Math.min(reconnectAttempt, 8));
logger("warn", "reconnect_scheduled", { reconnectAttempt, delayMs });
setTimeout(connect, delayMs);
}
function connect(): void {
const url = withToken(config.serverUrl, config.token);
logger("info", "connect_start", { serverUrl: config.serverUrl, providerId: config.providerId });
socket = new WebSocket(url);
socket.addEventListener("open", () => {
reconnectAttempt = 0;
logger("info", "connect_open", { providerId: config.providerId });
sendRegister();
sendHeartbeat();
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(sendHeartbeat, config.heartbeatIntervalMs);
});
socket.addEventListener("message", (event) => handleMessage(event as MessageEvent<string>));
socket.addEventListener("close", (event) => {
logger("warn", "connect_close", { code: event.code, reason: event.reason });
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
heartbeatTimer = null;
scheduleReconnect();
});
socket.addEventListener("error", () => {
logger("error", "connect_error", { providerId: config.providerId });
});
}
process.on("SIGTERM", () => {
stopping = true;
logger("warn", "sigterm_received");
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
socket?.close(1000, "provider shutdown");
process.exit(0);
});
connect();
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@unidesk/shared",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}
+104
View File
@@ -0,0 +1,104 @@
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type ProviderLabels = Record<string, JsonValue>;
export interface ProviderRegisterMessage {
type: "register";
providerId: string;
name: string;
labels: ProviderLabels;
startedAt: string;
capabilities: string[];
}
export interface ProviderHeartbeatMessage {
type: "heartbeat";
providerId: string;
labels: ProviderLabels;
at: string;
}
export interface ProviderTaskStatusMessage {
type: "task_status";
providerId: string;
taskId: string;
status: "accepted" | "running" | "succeeded" | "failed";
message: string;
at: string;
result?: JsonValue;
}
export interface CoreDispatchMessage {
type: "dispatch";
taskId: string;
command: "docker.ps" | "echo";
payload: Record<string, JsonValue>;
}
export interface CoreAcknowledgeMessage {
type: "ack";
requestId: string;
ok: boolean;
message: string;
}
export type ProviderToCoreMessage =
| ProviderRegisterMessage
| ProviderHeartbeatMessage
| ProviderTaskStatusMessage;
export type CoreToProviderMessage = CoreDispatchMessage | CoreAcknowledgeMessage;
export interface ApiNode {
providerId: string;
name: string;
status: "online" | "offline";
labels: ProviderLabels;
connectedAt: string | null;
lastHeartbeat: string | null;
}
export interface ApiTask {
id: string;
providerId: string;
command: string;
status: string;
payload: JsonValue;
result: JsonValue;
createdAt: string;
updatedAt: string;
}
export interface ApiEvent {
id: number;
type: string;
source: string;
payload: JsonValue;
createdAt: string;
}
export interface ServiceLogEntry {
ts: string;
service: string;
level: "debug" | "info" | "warn" | "error";
message: string;
data?: JsonValue;
}
export function parseJsonObject(value: string, name: string): Record<string, JsonValue> {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name} must be a JSON object`);
}
return parsed as Record<string, JsonValue>;
}
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
if (typeof value !== "object" || value === null || !("type" in value)) return false;
const msg = value as { type?: unknown; providerId?: unknown };
return (
(msg.type === "register" || msg.type === "heartbeat" || msg.type === "task_status") &&
typeof msg.providerId === "string" &&
msg.providerId.length > 0
);
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@unidesk/components",
"private": true,
"type": "module",
"workspaces": [
"components/*"
],
"scripts": {
"check": "tsc -b tsconfig.base.json"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"files": [],
"references": [
{ "path": "components/shared" },
{ "path": "components/backend-core" },
{ "path": "components/provider-gateway" },
{ "path": "components/frontend" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["components/**/*.ts"],
"exclude": ["components/**/dist/**"]
}