// --------------------------------------------------------------------------- // 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 { proxyFor, connectThroughProxy, proxiedRequestOptions } = require("./proxy"); 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", }, { id: "work", name: "Work", url: "https://outlook.office365.com/owa/calendar/60719e192e5d432bbc6851974499b640@ixsol.at/83a71169041649f5b94fbd6bdcb8b6e414431333084652976988/S-1-8-1561119972-3878098104-3591780966-3000395634/reachcalendar.ics", color: "#8ab06a", }, ]; 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 || ""; // Central outbound request helper: honors the outbound proxy (GITLAB_PROXY) // for the configured host — see api/proxy.js. async function request(url, options = {}) { const u = new URL(url); const client = u.protocol === "https:" ? https : http; const requestOptions = { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 15000, }; const proxy = proxyFor(url); if (proxy) { // https is tunneled through the proxy via CONNECT; plain http uses an // absolute-form request URI addressed to the proxy. let socket = null; if (u.protocol === "https:") { socket = await connectThroughProxy(proxy, url, requestOptions.timeout); } Object.assign(requestOptions, proxiedRequestOptions(proxy, url, socket)); } return new Promise((resolve, reject) => { const start = Date.now(); const req = client.request( u, requestOptions, (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); });