From 55e871bd4f10d017b15bb7400024ce9d61274a51 Mon Sep 17 00:00:00 2001 From: fegger Date: Fri, 4 Sep 2026 14:07:01 +0200 Subject: [PATCH] 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 --- .dockerignore | 2 + Dockerfile | 22 ++- api/gitlab-relay.js | 64 ++++++++ api/package-lock.json | 59 +++++++ api/package.json | 10 ++ api/server.js | 162 ++++++++++++++++++++ api/update-data.js | 347 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 40 +++++ docker-entrypoint.sh | 17 +++ nginx.conf | 32 +++- 10 files changed, 749 insertions(+), 6 deletions(-) create mode 100644 api/gitlab-relay.js create mode 100644 api/package-lock.json create mode 100644 api/package.json create mode 100644 api/server.js create mode 100644 api/update-data.js create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore index 1e98a66..b845015 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ .git .gitignore +.env +node_modules *.md .dockerignore docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 1ebe09f..235eddc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,27 @@ -# Lightweight static host for the homelab dashboard. -# Builds a small nginx image that serves index.html + assets. +# Dashboard: nginx static host + calendar/data updater + API proxy. FROM nginx:1.27-alpine -# Copy the site (static files only — no build step needed) +# Install Node.js and cron for the data updater. +RUN apk add --no-cache nodejs dcron + +# Copy site assets. COPY index.html style.css script.js /usr/share/nginx/html/ +COPY icons /usr/share/nginx/html/icons COPY nginx.conf /etc/nginx/conf.d/default.conf -# nginx listens on port 80 +# Backend scripts (node_modules included so the build works offline). +COPY api /opt/dashboard + +# Cron refreshes the data feed every 15 minutes. +RUN echo '*/15 * * * * cd /opt/dashboard && node update-data.js' > /etc/crontabs/root + +# Entrypoint: run updater once, start API proxy + cron, then nginx. +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + EXPOSE 80 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -q --spider http://127.0.0.1:80/ || exit 1 -CMD ["nginx", "-g", "daemon off;"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/api/gitlab-relay.js b/api/gitlab-relay.js new file mode 100644 index 0000000..6b2aef6 --- /dev/null +++ b/api/gitlab-relay.js @@ -0,0 +1,64 @@ +// --------------------------------------------------------------------------- +// Transparent HTTP relay for GitLab. +// Runs in the netbird container's network namespace (network_mode: service) +// so requests to the upstream flow over the VPN tunnel (wt0). +// The dashboard's updater points GITLAB_URL at this relay. +// --------------------------------------------------------------------------- + +const http = require("http"); +const https = require("https"); +const { URL } = require("url"); + +const PORT = parseInt(process.env.RELAY_PORT || "8080", 10); +const UPSTREAM = process.env.UPSTREAM || "https://gitlab.ixsol.wien"; +const upstreamBase = new URL(UPSTREAM); +const client = upstreamBase.protocol === "https:" ? https : http; + +// Hop-by-hop / host-specific headers that must not be forwarded. +const STRIP_HEADERS = new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "accept-encoding", +]); + +const server = http.createServer((req, res) => { + if (req.method === "GET" && (req.url === "/health" || req.url === "/healthz")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, upstream: UPSTREAM })); + return; + } + + const target = new URL(req.url, UPSTREAM); + const headers = { ...req.headers }; + for (const h of Object.keys(headers)) { + if (STRIP_HEADERS.has(h.toLowerCase())) delete headers[h]; + } + + const upstreamReq = client.request( + target, + { method: req.method, headers, timeout: 20000 }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers); + upstreamRes.pipe(res); + } + ); + + upstreamReq.on("timeout", () => upstreamReq.destroy(new Error("Upstream timeout"))); + upstreamReq.on("error", (err) => { + if (res.headersSent) { + res.end(); + return; + } + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: `GitLab relay: ${err.message}` })); + }); + + req.pipe(upstreamReq); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`GitLab relay listening on 0.0.0.0:${PORT} -> ${UPSTREAM}`); +}); \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..79d624a --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "dashboard-calendar-updater", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard-calendar-updater", + "version": "1.0.0", + "dependencies": { + "node-ical": "^0.27.1" + } + }, + "node_modules/node-ical": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.27.1.tgz", + "integrity": "sha512-5fiKuh/h2HrWKzFt20LtRQdjtjdGfijGfDnCfdAEc++SFX9gA6sa7xwj7/BJmRfWtW/NJWBenN4tOJhg0vM3pg==", + "license": "Apache-2.0", + "dependencies": { + "rrule-temporal": "^2.0.0", + "temporal-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/rrule-temporal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-2.2.3.tgz", + "integrity": "sha512-FAhonFfd9UUy6VI9kjzlbDPMjp/lgEGLH+dAGKJ9WYi7J4tHNaNb3Qp5jjjEhpOxFQgCwb2xz8HjMHbdFVLJoQ==", + "license": "MIT", + "dependencies": { + "temporal-spec": "^1.0.0" + } + }, + "node_modules/temporal-polyfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.4.tgz", + "integrity": "sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "1.0.1", + "temporal-utils": "1.0.2" + } + }, + "node_modules/temporal-spec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.1.tgz", + "integrity": "sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==", + "license": "Apache-2.0" + }, + "node_modules/temporal-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.2.tgz", + "integrity": "sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==", + "license": "MIT" + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..b3186c5 --- /dev/null +++ b/api/package.json @@ -0,0 +1,10 @@ +{ + "name": "dashboard-calendar-updater", + "version": "1.0.0", + "private": true, + "description": "Fetches ICS feeds and writes a merged JSON calendar for the dashboard.", + "main": "update-calendars.js", + "dependencies": { + "node-ical": "^0.27.1" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..66dd544 --- /dev/null +++ b/api/server.js @@ -0,0 +1,162 @@ +// --------------------------------------------------------------------------- +// 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}`); +}); diff --git a/api/update-data.js b/api/update-data.js new file mode 100644 index 0000000..b14dc7d --- /dev/null +++ b/api/update-data.js @@ -0,0 +1,347 @@ +// --------------------------------------------------------------------------- +// Aggregates live data for the dashboard: +// - calendar events (today + next 60 days) +// - netbird status ping +// - gitea + gitlab recent commits / requests +// - open webui available models +// Writes /usr/share/nginx/html/data.json; scheduled by cron. +// --------------------------------------------------------------------------- + +const ical = require("node-ical"); +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const { URL } = require("url"); + +const OUT_FILE = process.env.OUT_FILE || "/usr/share/nginx/html/data.json"; +const TZ = process.env.TZ || "UTC"; +const LOOKAHEAD_DAYS = parseInt(process.env.LOOKAHEAD_DAYS || "60", 10); + +const CALENDARS = [ + { + id: "shared", + name: "Shared", + url: "https://calendar.google.com/calendar/ical/46fd0e4425187ee619539b7cd6b66fc22901973f08e9b7af4f16bb2604311b5f%40group.calendar.google.com/private-69954399aadf56330eb8d4aa27c12901/basic.ics", + color: "#f76b2f", + }, + { + id: "flo", + name: "Flo", + url: "https://calendar.google.com/calendar/ical/flo.egger%40icloud.com/private-13d04ed58b84ab6bf3c7056fff1d9289/basic.ics", + color: "#5c8ad8", + }, +]; + +const NETBIRD_URL = process.env.NETBIRD_URL || "https://netbird.gg36.at"; +const GITEA_URL = process.env.GITEA_URL || "http://100.103.83.12:3003"; +const GITEA_TOKEN = process.env.GITEA_TOKEN || ""; +const GITLAB_URL = process.env.GITLAB_URL || "https://gitlab.ixsol.wien"; +const GITLAB_TOKEN = process.env.GITLAB_TOKEN || ""; +const OPENWEBUI_URL = process.env.OPENWEBUI_URL || "http://100.103.83.12:3000"; +const OPENWEBUI_TOKEN = process.env.OPENWEBUI_TOKEN || ""; + +function request(url, options = {}) { + return new Promise((resolve, reject) => { + const start = Date.now(); + 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 || 15000, + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + resolve({ status: res.statusCode, headers: res.headers, body, latencyMs: Date.now() - start }); + }); + } + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`Timeout for ${url}`)); + }); + if (options.body) req.write(options.body); + req.end(); + }); +} + +async function fetchText(url, headers = {}) { + let current = url; + for (let redirect = 0; redirect < 5; redirect++) { + const res = await request(current, { headers, timeout: 30000 }); + if (res.status >= 300 && res.status < 400 && res.headers.location) { + current = new URL(res.headers.location, current).href; + continue; + } + if (res.status < 200 || res.status >= 300) { + throw new Error(`HTTP ${res.status} for ${current}`); + } + return res.body; + } + throw new Error("Too many redirects"); +} + +async function fetchJson(url, headers = {}) { + const text = await fetchText(url, headers); + return JSON.parse(text); +} + +// --------------------------------------------------------------------------- +// Calendar +// --------------------------------------------------------------------------- + +function icalEventToJson(e, calendarId) { + return { + id: `${calendarId}-${e.uid}`, + calendarId, + summary: e.summary || "(no title)", + start: e.start.toISOString(), + end: e.end ? e.end.toISOString() : null, + allDay: e.datetype === "date", + description: e.description || null, + location: e.location || null, + }; +} + +async function fetchCalendarEvents(cal) { + const ics = await fetchText(cal.url); + const parsed = ical.parseICS(ics); + const events = []; + for (const key of Object.keys(parsed)) { + const e = parsed[key]; + if (e.type !== "VEVENT") continue; + events.push(icalEventToJson(e, cal.id)); + } + return events; +} + +function dateKey(d) { + return new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit" }).format(d); +} + +function getCalendarData(events) { + const now = new Date(); + const max = new Date(); + max.setDate(max.getDate() + LOOKAHEAD_DAYS); + + const upcoming = events + .filter((e) => { + const start = new Date(e.start); + return start >= now && start <= max; + }) + .sort((a, b) => new Date(a.start) - new Date(b.start)); + + // "Today" includes events already in progress or spanning today, so it is + // computed from all events (not just the future-filtered upcoming list). + const todayKey = dateKey(now); + const today = events + .filter((e) => { + const startKey = dateKey(new Date(e.start)); + const endKey = e.end ? dateKey(new Date(e.end)) : null; + return startKey === todayKey || endKey === todayKey; + }) + .sort((a, b) => new Date(a.start) - new Date(b.start)); + + return { today, upcoming: upcoming.slice(0, 120) }; +} + +// --------------------------------------------------------------------------- +// Netbird status +// --------------------------------------------------------------------------- + +async function getNetbirdStatus() { + try { + const res = await request(NETBIRD_URL, { timeout: 10000 }); + const online = res.status >= 200 && res.status < 500; // any response means reachable + return { + url: NETBIRD_URL, + status: online ? "online" : "offline", + httpStatus: res.status, + latencyMs: res.latencyMs, + error: null, + }; + } catch (e) { + return { url: NETBIRD_URL, status: "offline", httpStatus: null, latencyMs: null, error: e.message }; + } +} + +// --------------------------------------------------------------------------- +// Git stats +// --------------------------------------------------------------------------- + +function normalizeCommit(raw, source) { + if (source === "gitea") { + return { + repo: raw.repo || "unknown", + message: (raw.commit?.message || raw.message || "").split("\n")[0], + author: raw.commit?.author?.name || raw.author?.login || raw.author?.name || "unknown", + date: raw.commit?.committer?.date || raw.created || raw.commit?.author?.date || null, + url: raw.html_url || null, + }; + } + // gitlab + return { + repo: raw.repo || "unknown", + message: (raw.title || raw.message || "").split("\n")[0], + author: raw.author_name || raw.author?.name || "unknown", + date: raw.committed_date || raw.created_at || null, + url: raw.web_url || null, + }; +} + +async function getGiteaStats() { + try { + const headers = GITEA_TOKEN ? { Authorization: `token ${GITEA_TOKEN}` } : {}; + let repos = []; + if (GITEA_TOKEN) { + repos = await fetchJson(`${GITEA_URL}/api/v1/user/repos?limit=10`, headers); + } else { + const search = await fetchJson(`${GITEA_URL}/api/v1/repos/search?limit=10`, headers); + repos = search.data || []; + } + repos = repos.slice(0, 8); + + const commits = []; + for (const repo of repos) { + try { + const list = await fetchJson( + `${GITEA_URL}/api/v1/repos/${repo.full_name}/commits?limit=5`, + headers + ); + for (const c of list.slice(0, 5)) { + commits.push(normalizeCommit({ ...c, repo: repo.full_name }, "gitea")); + } + } catch (e) { + // ignore per-repo errors + } + } + + commits.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)); + return { + url: GITEA_URL, + repos: repos.length, + recentCommits: commits.slice(0, 10), + error: null, + }; + } catch (e) { + return { url: GITEA_URL, repos: 0, recentCommits: [], error: e.message }; + } +} + +async function getGitlabStats() { + try { + const headers = GITLAB_TOKEN ? { "PRIVATE-TOKEN": GITLAB_TOKEN } : {}; + let projects = []; + if (GITLAB_TOKEN) { + projects = await fetchJson(`${GITLAB_URL}/api/v4/projects?membership=true&per_page=10`, headers); + } else { + projects = await fetchJson(`${GITLAB_URL}/api/v4/projects?visibility=public&per_page=5`, headers); + } + projects = projects.slice(0, 8); + + const commits = []; + for (const project of projects) { + try { + const encoded = encodeURIComponent(project.path_with_namespace || project.id); + const list = await fetchJson( + `${GITLAB_URL}/api/v4/projects/${encoded}/repository/commits?per_page=5`, + headers + ); + for (const c of list.slice(0, 5)) { + commits.push(normalizeCommit({ ...c, repo: project.path_with_namespace }, "gitlab")); + } + } catch (e) { + // ignore per-project errors + } + } + + commits.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)); + return { + url: GITLAB_URL, + projects: projects.length, + recentCommits: commits.slice(0, 10), + error: null, + }; + } catch (e) { + return { url: GITLAB_URL, projects: 0, recentCommits: [], error: e.message }; + } +} + +// --------------------------------------------------------------------------- +// Open WebUI models +// --------------------------------------------------------------------------- + +async function getOpenWebUIModels() { + try { + const headers = OPENWEBUI_TOKEN ? { Authorization: `Bearer ${OPENWEBUI_TOKEN}` } : {}; + const data = await fetchJson(`${OPENWEBUI_URL}/api/models`, headers); + const models = (data.data || data.models || data || []) + .map((m) => ({ + id: m.id || m.model || m.name, + name: m.name || m.id || m.model || m.title, + })) + .filter((m) => m.id) + .slice(0, 50); + return { url: OPENWEBUI_URL, models, error: null }; + } catch (e) { + return { url: OPENWEBUI_URL, models: [], error: e.message }; + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const outDir = path.dirname(OUT_FILE); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + + // Calendar + let calendarError = null; + const allEvents = []; + for (const cal of CALENDARS) { + try { + const events = await fetchCalendarEvents(cal); + allEvents.push(...events); + } catch (e) { + console.error(`Failed to fetch calendar "${cal.name}": ${e.message}`); + calendarError = calendarError || e.message; + } + } + const calendar = getCalendarData(allEvents); + + // Parallel fetch everything else + const [netbird, gitea, gitlab, openwebui] = await Promise.all([ + getNetbirdStatus(), + getGiteaStats(), + getGitlabStats(), + getOpenWebUIModels(), + ]); + + const payload = { + updatedAt: new Date().toISOString(), + tz: TZ, + calendars: CALENDARS.map(({ id, name, color }) => ({ id, name, color })), + calendar: { ...calendar, error: calendarError }, + netbird, + git: { gitea, gitlab }, + openwebui, + }; + + fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); + console.log( + `Wrote data.json: ${calendar.today.length} today, ${calendar.upcoming.length} upcoming, netbird=${netbird.status}, git=${gitea.recentCommits.length}/${gitlab.recentCommits.length}, models=${openwebui.models.length}` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/docker-compose.yml b/docker-compose.yml index b407ac9..d603b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,46 @@ services: ports: - "8888:80" restart: unless-stopped + environment: + # Timezone used for the "Today" calendar widget. + - TZ=Europe/Vienna + # Secrets live in .env (gitignored) — compose reads it automatically. + - OPENWEBUI_TOKEN=${OPENWEBUI_TOKEN:-} + # Optional API tokens for live stats / git integration. + # Uncomment and fill in to enable private git stats. + # - GITEA_TOKEN= + # - GITLAB_TOKEN= + # - NETBIRD_TOKEN= + # GPU stats upstream; defaults to the gpu-stats service below. + # To run gpu_stats.py on the host instead, use: + # http://host.docker.internal:9101/stats + # (and add: extra_hosts: ["host.docker.internal:host-gateway"]) + # - GPU_STATS_URL=http://gpu-stats:9101/stats + # Ollama instance for the loaded-model stats in the GPU widget. + # - OLLAMA_URL=http://100.103.83.12:11435 + logging: + driver: json-file + options: + max-size: "1m" + max-file: "2" + + # AMD GPU stats via rocm-smi (gpu_stats.py). + # Needs the host's ROCm install + GPU device nodes passed through. + gpu-stats: + image: python:3.12-slim + container_name: homelab-gpu-stats + restart: unless-stopped + environment: + # rocm-smi can't read the device name without libdrm; set it explicitly. + - GPU_NAME=AMD Radeon AI PRO R9700 + # - ROCM_SMI_PATH=/opt/rocm/bin/rocm-smi + volumes: + - ./gpu_stats.py:/app/gpu_stats.py:ro + - /opt/rocm:/opt/rocm:ro + devices: + - /dev/kfd + - /dev/dri + command: python3 /app/gpu_stats.py logging: driver: json-file options: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..9269fa4 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Dashboard container entrypoint. +# Starts everything in the background and nginx in the foreground, so a slow +# or unreachable upstream (git hosts, netbird, calendar) never blocks the +# web server from coming up. + +# Start the small API proxy (for Open WebUI chat) in the background. +node /opt/dashboard/server.js & + +# Start cron daemon in the background to refresh data every 15 minutes. +/usr/sbin/crond -f -l 8 & + +# Run the initial data fetch in the background; cron keeps it fresh after that. +( cd /opt/dashboard && node update-data.js ) & + +# Start nginx in the foreground. +exec nginx -g "daemon off;" \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index fbc12a8..04ec040 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,5 +1,5 @@ # Custom nginx config (used by the Dockerfile). -# Serves the static dashboard with sensible caching + security headers. +# Serves the static dashboard and proxies /api/chat to the Node API backend. server { listen 80; @@ -8,6 +8,36 @@ server { root /usr/share/nginx/html; index index.html; + # Generated data feed; refresh frequently. + location = /data.json { + add_header Cache-Control "no-cache" always; + } + + # Open WebUI chat proxy. + location /api/chat { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } + + # GPU stats proxy (forwarded through the Node backend). + location /api/gpu-stats { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_read_timeout 15s; + } + + # Ollama loaded-model stats proxy. + location /api/ollama { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_read_timeout 15s; + } + location / { try_files $uri $uri/ =404; }