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:
2026-09-04 14:07:01 +02:00
parent 9c3eba55db
commit 55e871bd4f
10 changed files with 749 additions and 6 deletions
+2
View File
@@ -1,5 +1,7 @@
.git
.gitignore
.env
node_modules
*.md
.dockerignore
docker-compose.yml
+17 -5
View File
@@ -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"]
+64
View File
@@ -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}`);
});
+59
View File
@@ -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"
}
}
}
+10
View File
@@ -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
View File
@@ -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}`);
});
+347
View File
@@ -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);
});
+40
View File
@@ -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:
+17
View File
@@ -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
View File
@@ -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;
}