feat: initialize unidesk platform
This commit is contained in:
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@unidesk/frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@unidesk/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "components/shared" },
|
||||
{ "path": "components/backend-core" },
|
||||
{ "path": "components/provider-gateway" },
|
||||
{ "path": "components/frontend" }
|
||||
]
|
||||
}
|
||||
@@ -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/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user