55e871bd4f
- 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
163 lines
5.2 KiB
JavaScript
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}`);
|
|
});
|