Files
dashboard/api/server.js
T
fegger 55e871bd4f Add backend data aggregator and API proxy services
- update-data.js: cron job that aggregates Google Calendar ICS feeds
  (today + upcoming, node-ical), Netbird reachability, Gitea/GitLab
  commit stats and Open WebUI models into data.json
- server.js: small Node proxy behind nginx exposing /api/chat
  (Open WebUI completions), /api/gpu-stats and /api/ollama
- gitlab-relay.js: prepared HTTP relay for GitLab over a Netbird VPN
  (not wired up yet; the setup key was rejected by vpn.ixsol.at)
- docker-entrypoint.sh: starts updater in the background, API proxy,
  cron (15 min refresh) and nginx without blocking startup
- nginx: proxy locations + no-cache for data.json
- compose: TZ, token env wiring from .env, gpu-stats service
  (python + rocm-smi with device passthrough)
- dockerignore: keep .env out of the build context
2026-09-04 14:07:01 +02:00

163 lines
5.2 KiB
JavaScript

// ---------------------------------------------------------------------------
// Tiny API proxy for interactive dashboard features.
// Runs inside the container on localhost; nginx proxies /api/chat here.
// ---------------------------------------------------------------------------
const http = require("http");
const https = require("https");
const { URL } = require("url");
const PORT = process.env.API_PORT || 3001;
const OPENWEBUI_URL = process.env.OPENWEBUI_URL || "http://100.103.83.12:3000";
const OPENWEBUI_TOKEN = process.env.OPENWEBUI_TOKEN || "";
const GPU_STATS_URL = process.env.GPU_STATS_URL || "http://gpu-stats:9101/stats";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://100.103.83.12:11435";
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const client = u.protocol === "https:" ? https : http;
const req = client.request(
u,
{
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 120000,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (body += chunk));
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body }));
}
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Timeout"));
});
if (options.body) req.write(options.body);
req.end();
});
}
function jsonResponse(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
const server = http.createServer(async (req, res) => {
// Very small CORS allowlist: only dashboard origin, but since this sits behind
// nginx on the same origin we can keep it strict.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (url.pathname === "/health" && req.method === "GET") {
return jsonResponse(res, 200, { ok: true });
}
// Always drain the request body before responding, otherwise leftover bytes
// corrupt keep-alive connections (e.g. nginx upstream keepalive).
req.resume();
if (url.pathname === "/api/gpu-stats" && req.method === "GET") {
try {
const upstream = await request(GPU_STATS_URL, { timeout: 8000 });
res.writeHead(upstream.status, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
});
res.end(upstream.body);
} catch (e) {
return jsonResponse(res, 502, { error: `GPU stats unreachable: ${e.message}` });
}
}
if (url.pathname === "/api/ollama" && req.method === "GET") {
try {
const upstream = await request(`${OLLAMA_URL}/api/ps`, { timeout: 8000 });
res.writeHead(upstream.status, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
});
res.end(upstream.body);
} catch (e) {
return jsonResponse(res, 502, { error: `Ollama unreachable: ${e.message}` });
}
}
if (url.pathname === "/api/chat" && req.method === "POST") {
if (!OPENWEBUI_TOKEN) {
return jsonResponse(res, 503, { error: "OPENWEBUI_TOKEN not configured" });
}
try {
const raw = await readBody(req);
const body = JSON.parse(raw || "{}");
const { model, prompt } = body;
if (!model || !prompt) {
return jsonResponse(res, 400, { error: "model and prompt are required" });
}
const payload = JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
});
const upstream = await request(`${OPENWEBUI_URL}/api/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENWEBUI_TOKEN}`,
},
body: payload,
});
if (upstream.status < 200 || upstream.status >= 300) {
return jsonResponse(res, upstream.status, { error: `Open WebUI ${upstream.status}`, body: upstream.body });
}
const data = JSON.parse(upstream.body);
const message = data.choices?.[0]?.message?.content || data.response || "(no response)";
return jsonResponse(res, 200, { message });
} catch (e) {
return jsonResponse(res, 500, { error: e.message });
}
}
res.writeHead(404);
res.end("Not found");
});
// This is a tiny internal proxy: never let a per-request error kill the
// process (that would take down the GPU stats proxy too).
process.on("uncaughtException", (err) => {
console.error("uncaughtException:", err);
});
process.on("unhandledRejection", (err) => {
console.error("unhandledRejection:", err);
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`Dashboard API proxy listening on 127.0.0.1:${PORT}`);
});