375 lines
12 KiB
JavaScript
375 lines
12 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 { 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",
|
|
},
|
|
];
|
|
|
|
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);
|
|
});
|