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
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
node_modules
|
||||
*.md
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
+17
-5
@@ -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"]
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+162
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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;"
|
||||
+31
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user