Files
dashboard/api/update-data.js
T
fegger c15cfc51a9 Fall back to public Gitea stats when the API token is rejected
Tokens without scopes (Gitea >= 1.19) or revoked tokens get 403 on
/api/v1/user/repos; catch that and retry anonymously via /repos/search
so the Git widget keeps showing public stats instead of an error.
2026-09-04 17:38:13 +02:00

359 lines
11 KiB
JavaScript

// ---------------------------------------------------------------------------
// 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 {
let repos = [];
let authHeaders = {};
// Try the token-authenticated path first (includes private repos).
if (GITEA_TOKEN) {
try {
authHeaders = { Authorization: `token ${GITEA_TOKEN}` };
repos = await fetchJson(`${GITEA_URL}/api/v1/user/repos?limit=10`, authHeaders);
} catch {
// Token missing scopes / revoked — fall back to anonymous public stats.
authHeaders = {};
repos = [];
}
}
if (repos.length === 0) {
const search = await fetchJson(`${GITEA_URL}/api/v1/repos/search?limit=10`, {});
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`,
authHeaders
);
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);
});