Files
dashboard/script.js
T
fegger dfe246b7ca Add Outlook unread-mail widget with device-code login
- outlook-auth.js: one-time device-code flow against Microsoft login;
  stores the refresh token in the outlook-tokens compose volume
- update-outlook.js: per-minute cron fetching the unread count and the
  latest unread messages via Microsoft Graph; caches access tokens and
  persists rotated refresh tokens
- Widget is hidden until the mailbox is actually authorized, so it
  stays out of sight while (admin) consent is pending
- New external card for Outlook (Office 365) with the official icon
- compose: pass OUTLOOK_CLIENT_ID/OUTLOOK_TENANT/GITEA_TOKEN from .env
- README: Entra ID app registration steps and the tenant-ID hint for
  directories not resolvable via the organizations endpoint
2026-09-04 17:38:17 +02:00

732 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// Homelab Dashboard
// Edit this array to add, remove, or reorganise services.
// section : "internal" | "external"
// name : display name
// url : full URL (with http:// or https://)
// cat : a key from the CATEGORIES map below
// icon : an emoji (or any short text), or HTML such as
// '<img class="card-logo" src="icons/jellyfin.svg" alt="" />'
// ---------------------------------------------------------------------------
const CATEGORIES = {
ai: { label: "AI", color: "#c970b0" },
dev: { label: "Development", color: "#5c8ad8" },
media: { label: "Media", color: "#e0554a" },
photos: { label: "Photos", color: "#8ab06a" },
smartHome: { label: "Smart Home", color: "#f2a944" },
audio: { label: "Audio", color: "#f76b2f" },
storage: { label: "Storage", color: "#5c8ad8" },
security: { label: "Security", color: "#e0554a" },
infra: { label: "Infrastructure", color: "#8c8f94" },
network: { label: "Networking", color: "#f76b2f" },
external: { label: "External", color: "#c970b0" },
};
const SERVICES = [
// ----- Internal -----
{ section: "internal", name: "Open WebUI", url: "http://100.103.83.12:3000", cat: "ai", icon: '<img class="card-logo" src="icons/openwebui.svg" alt="" />', chat: true },
{ section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: '<img class="card-logo" src="icons/comfyui.svg" alt="" />' },
{ section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: '<img class="card-logo" src="icons/gitea.svg" alt="" />' },
{ section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: '<img class="card-logo" src="icons/jellyfin.svg" alt="" />' },
{ section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: '<img class="card-logo" src="icons/immich.svg" alt="" />' },
{ section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: '<img class="card-logo" src="icons/homeassistant.svg" alt="" />' },
{ section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: '<img class="card-logo" src="icons/audio.svg" alt="" />' },
{ section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: '<img class="card-logo" src="icons/nextcloud.svg" alt="" />' },
{ section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: '<img class="card-logo" src="icons/vaultwarden.svg" alt="" />' },
{ section: "internal", name: "Nginx Proxy Manager", url: "http://192.168.178.194:81", cat: "infra", icon: "⚙️" },
{ section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: '<img class="card-logo" src="icons/netbird.svg" alt="" />', liveStatus: "netbird" },
// ----- External -----
{ section: "external", name: "Outlook (Office 365)", url: "https://outlook.office.com/mail/", cat: "external", icon: '<img class="card-logo" src="icons/outlook.ico" alt="" />' },
{ section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '<img class="card-logo" src="icons/gitlab.svg" alt="" />' },
{ section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '<img class="card-logo" src="icons/odoo.svg" alt="" />' },
];
const SECTION_META = {
internal: { title: "Internal Services", order: 1 },
external: { title: "External", order: 2 },
};
// ---------------------------------------------------------------------------
// Data feed (calendar + widgets). One fetch serves both; retries shortly
// after boot because the server-side updater may still be running.
// ---------------------------------------------------------------------------
let dataRetries = 0;
async function loadData() {
let data;
try {
const res = await fetch("data.json", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
dataRetries = 0;
} catch (err) {
const msg = escapeHtml(err.message);
document.getElementById("today-events").innerHTML = `<p class="widget-error">${msg}</p>`;
document.getElementById("git-stats").innerHTML = `<p class="widget-error">${msg}</p>`;
const nbEl = document.querySelector('[data-live-status="netbird"]');
if (nbEl) nbEl.innerHTML = `<span class="widget-error">${msg}</span>`;
const calendarEl = document.getElementById("calendar");
calendarEl.innerHTML = `<p class="calendar-error">Data unavailable: ${msg}</p>`;
document.getElementById("calendar-count").textContent = "0";
if (dataRetries < 3) {
dataRetries++;
setTimeout(loadData, 30000);
}
return;
}
renderWidgets(data);
renderCalendar(data);
}
function renderWidgets(data) {
renderTodayWidget(data.calendar?.today || [], data.calendars || []);
renderNetbirdWidget(data.netbird);
renderGitWidget(data.git);
if (data.openwebui?.models?.length) {
populateOpenWebUIModels(data.openwebui.models);
} else {
document.querySelectorAll(".chat-model").forEach((select) => {
select.innerHTML = `<option value="" disabled selected>No models — set OPENWEBUI_TOKEN</option>`;
});
}
}
function renderCalendar(data) {
const calendarEl = document.getElementById("calendar");
const countEl = document.getElementById("calendar-count");
const calMap = Object.fromEntries((data.calendars || []).map((c) => [c.id, c]));
const upcoming = (data.calendar?.upcoming || []).slice(0, 60);
if (upcoming.length === 0) {
calendarEl.innerHTML = `<p class="calendar-empty">No upcoming events.</p>`;
countEl.textContent = "0";
return;
}
const grouped = groupEventsByDay(upcoming);
calendarEl.innerHTML = Object.entries(grouped)
.map(([day, events]) => {
return `
<div class="calendar-day">
<div class="calendar-day-header">${escapeHtml(day)}</div>
<div class="calendar-events">
${events.map((e) => renderEvent(e, calMap[e.calendarId])).join("")}
</div>
</div>`;
})
.join("");
countEl.textContent = String(upcoming.length);
}
function groupEventsByDay(events) {
const groups = {};
const fmt = new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "numeric",
month: "short",
});
for (const e of events) {
const key = fmt.format(new Date(e.start));
(groups[key] ||= []).push(e);
}
return groups;
}
function renderEvent(e, cal) {
const start = new Date(e.start);
const end = e.end ? new Date(e.end) : null;
const calColor = cal?.color || "var(--accent)";
let timeText;
if (e.allDay) {
timeText = "All day";
} else {
const fmt = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" });
if (end) {
timeText = `${fmt.format(start)} ${fmt.format(end)}`;
} else {
timeText = fmt.format(start);
}
}
const location = e.location ? `<div class="event-location">📍 ${escapeHtml(e.location)}</div>` : "";
const description = e.description
? `<div class="event-description">${escapeHtml(e.description.split("\n")[0])}</div>`
: "";
return `
<article class="calendar-event" style="--cal:${calColor}">
<div class="event-dot" aria-hidden="true"></div>
<div class="event-body">
<div class="event-time">${escapeHtml(timeText)}</div>
<div class="event-title">${escapeHtml(e.summary)}</div>
${location}
${description}
</div>
</article>`;
}
// ---------------------------------------------------------------------------
// Top widgets (today, netbird, git)
// ---------------------------------------------------------------------------
function renderTodayWidget(events, calendars) {
const el = document.getElementById("today-events");
const calMap = Object.fromEntries(calendars.map((c) => [c.id, c]));
if (events.length === 0) {
el.innerHTML = `<p class="widget-empty">No events today.</p>`;
return;
}
el.innerHTML = events
.sort((a, b) => new Date(a.start) - new Date(b.start))
.map((e) => {
const cal = calMap[e.calendarId];
const color = cal?.color || "var(--accent)";
const time = e.allDay ? "All day" : new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(e.start));
const startT = new Date(e.start).getTime();
const endT = e.end ? new Date(e.end).getTime() : startT;
const nowT = Date.now();
let state = "";
if (!e.allDay) {
if (nowT > endT) state = "past";
else if (nowT >= startT) state = "now";
}
return `
<div class="today-event ${state}" style="--cal:${color}">
<span class="today-dot" aria-hidden="true"></span>
<span class="today-time">${escapeHtml(time)}</span>
<span class="today-summary">${escapeHtml(e.summary)}</span>
</div>`;
})
.join("");
}
function renderNetbirdWidget(status) {
// Renders into the Netbird service card (data-live-status="netbird").
const el = document.querySelector('[data-live-status="netbird"]');
if (!el) return;
if (!status) {
el.innerHTML = `<span class="widget-error">Status unavailable</span>`;
return;
}
const online = status.status === "online";
const dotClass = online ? "online" : "offline";
const text = online ? "Reachable" : status.error || "Unreachable";
const latency = status.latencyMs ? `<span class="netbird-latency"> · ${status.latencyMs} ms</span>` : "";
el.innerHTML = `
<span class="netbird-dot ${dotClass}" aria-hidden="true"></span>
<span class="netbird-text">${escapeHtml(text)}${latency}</span>`;
}
function renderGitWidget(git) {
const el = document.getElementById("git-stats");
if (!git) {
el.innerHTML = `<p class="widget-empty">Git stats unavailable.</p>`;
return;
}
const gitea = git.gitea || { repos: 0, recentCommits: [], error: null };
const gitlab = git.gitlab || { projects: 0, recentCommits: [], error: null };
const giteaError = gitea.error ? `<div class="git-error">Gitea: ${escapeHtml(gitea.error)}</div>` : "";
const gitlabError = gitlab.error ? `<div class="git-error">GitLab: ${escapeHtml(gitlab.error)}</div>` : "";
const allCommits = [...gitea.recentCommits, ...gitlab.recentCommits]
.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
.slice(0, 8);
let commitsHtml;
if (allCommits.length === 0) {
commitsHtml = `<div class="widget-empty">No recent commits visible.</div>`;
} else {
// The list is capped to ~3 visible rows via CSS (max-height) and scrolls.
commitsHtml = `<ul class="git-commits">
${allCommits
.map((c) => {
const repo = escapeHtml(c.repo).split("/").pop();
const msg = escapeHtml(c.message);
const author = escapeHtml(c.author);
const when = c.date
? new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(c.date))
: "";
return `<li class="git-commit" title="${escapeHtml(c.repo)}">
<span class="git-repo">${repo}</span>
<span class="git-msg">${msg}</span>
<span class="git-meta">${author}${when ? ` · ${when}` : ""}</span>
</li>`;
})
.join("")}
</ul>`;
}
el.innerHTML = `
<div class="git-summary">
<span class="git-badge gitea">Gitea ${gitea.repos || 0}</span>
<span class="git-badge gitlab">GitLab ${gitlab.projects || 0}</span>
<span class="git-badge total">Commits ${gitea.recentCommits.length + gitlab.recentCommits.length}</span>
</div>
${giteaError}
${gitlabError}
${commitsHtml}`;
}
// ---------------------------------------------------------------------------
// GPU widget (live rocm-smi stats via /api/gpu-stats)
// ---------------------------------------------------------------------------
function renderGpuWidget(g) {
const el = document.getElementById("gpu-stats");
if (!g || g.error) {
el.innerHTML = `<p class="widget-error">${escapeHtml(g?.error || "GPU stats unavailable")}</p>`;
return;
}
const util = Math.max(0, Math.min(100, g.utilization ?? 0));
const memPct = g.memory_total
? Math.max(0, Math.min(100, Math.round((g.memory_used / g.memory_total) * 100)))
: 0;
const gb = (mb) => (mb / 1024).toFixed(1);
const temp = g.temperature ?? null;
let tempClass = "";
if (temp != null) {
if (temp >= 80) tempClass = "hot";
else if (temp >= 65) tempClass = "warm";
}
el.innerHTML = `
<div class="gpu-name" title="${escapeHtml(g.name || "GPU")}">${escapeHtml(g.name || "GPU")}</div>
<div class="gpu-row">
<span class="gpu-label">Utilization</span>
<span class="gpu-val">${util}%</span>
</div>
<div class="gpu-bar"><div class="gpu-bar-fill util" style="width:${util}%"></div></div>
<div class="gpu-row">
<span class="gpu-label">VRAM</span>
<span class="gpu-val">${gb(g.memory_used || 0)} / ${gb(g.memory_total || 0)} GB</span>
</div>
<div class="gpu-bar"><div class="gpu-bar-fill mem" style="width:${memPct}%"></div></div>
<div class="gpu-meta">
${temp != null ? `<span class="gpu-temp ${tempClass}">${temp}°C</span>` : ""}
${g.power_w != null ? `<span>${Math.round(g.power_w)} W</span>` : ""}
</div>`;
}
async function loadGpuStats() {
try {
const res = await fetch("/api/gpu-stats", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
renderGpuWidget(data);
} catch (err) {
document.getElementById("gpu-stats").innerHTML =
`<p class="widget-error">${escapeHtml(err.message)}</p>`;
}
}
// ---------------------------------------------------------------------------
// Ollama stats (loaded models + GPU/CPU split) via /api/ollama
// ---------------------------------------------------------------------------
function renderOllamaStats(data) {
const el = document.getElementById("ollama-stats");
const models = data?.models || [];
if (models.length === 0) {
el.innerHTML = `<div class="widget-empty">No model loaded</div>`;
return;
}
const gib = (bytes) => `${(bytes / 1073741824).toFixed(1)} GiB`;
el.innerHTML = models
.map((m) => {
const size = m.size || 0;
const vram = m.size_vram ?? 0;
const gpuPct = size > 0 ? Math.max(0, Math.min(100, Math.round((vram / size) * 100))) : 0;
const cpuPct = 100 - gpuPct;
const name = (m.name || m.model || "model").replace(":latest", "");
return `
<div class="ollama-model">
<div class="ollama-model-head">
<span class="ollama-name" title="${escapeHtml(m.name || "")}">${escapeHtml(name)}</span>
<span class="ollama-size">${gib(size)}</span>
</div>
<div class="ollama-split">
<span class="ollama-gpu">GPU ${gpuPct}%</span> · <span class="ollama-cpu">CPU ${cpuPct}%</span>
</div>
</div>`;
})
.join("");
}
async function loadOllamaStats() {
try {
const res = await fetch("/api/ollama", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
renderOllamaStats(data);
} catch (err) {
document.getElementById("ollama-stats").innerHTML =
`<p class="widget-error">${escapeHtml(err.message)}</p>`;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
const app = document.getElementById("app");
const searchInput = document.getElementById("search");
const statsEl = document.getElementById("stats");
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function cardTemplate(s, idx) {
const meta = CATEGORIES[s.cat] || { label: s.cat || "Service", color: "var(--accent)" };
const urlHost = s.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
const chatForm = s.chat
? `<div class="card-chat" data-chat-url="${escapeHtml(s.url)}">
<div class="chat-row">
<select class="chat-model" aria-label="Model">
<option value="" disabled selected>Loading models…</option>
</select>
<input type="text" class="chat-prompt" placeholder="Ask something…" autocomplete="off" />
<button class="chat-send" type="button" title="Send prompt">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
<div class="chat-response" aria-live="polite"></div>
</div>`
: "";
return `
<article class="card" data-search="${escapeHtml((s.name + " " + meta.label + " " + s.url).toLowerCase())}"
style="--cat:${meta.color}; animation-delay:${Math.min(idx * 35, 400)}ms">
<div class="card-head">
<div class="card-icon" aria-hidden="true">${s.icon || "🔗"}</div>
<span class="status" data-status-url="${escapeHtml(s.url)}">
<span class="dot" aria-hidden="true"></span>
<span class="status-text">Checking…</span>
</span>
</div>
<div class="card-body">
<span class="card-cat">${escapeHtml(meta.label)}</span>
<h3 class="card-name">${escapeHtml(s.name)}</h3>
<span class="card-url" title="${escapeHtml(s.url)}">${escapeHtml(urlHost)}</span>
</div>
${s.liveStatus ? `<div class="card-live-status" data-live-status="${escapeHtml(s.liveStatus)}"><span class="widget-loading">Checking status…</span></div>` : ""}
<div class="card-actions">
<a class="btn btn-primary" href="${escapeHtml(s.url)}" target="_blank" rel="noopener noreferrer">
Open
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
d="M7 17 17 7M9 7h8v8"/>
</svg>
</a>
<button class="btn btn-ghost" type="button" data-copy="${escapeHtml(s.url)}" title="Copy URL"
aria-label="Copy URL for ${escapeHtml(s.name)}">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path fill="none" stroke="currentColor" stroke-width="2" d="M5 15V5a2 2 0 0 1 2-2h10"/>
</svg>
</button>
</div>
${chatForm}
</article>`;
}
function render() {
const grouped = {};
for (const s of SERVICES) (grouped[s.section] ||= []).push(s);
const sections = Object.keys(grouped).sort(
(a, b) => (SECTION_META[a]?.order ?? 99) - (SECTION_META[b]?.order ?? 99),
);
app.innerHTML = sections
.map((key) => {
const meta = SECTION_META[key] || { title: key };
const cards = grouped[key].map((s, i) => cardTemplate(s, i)).join("");
return `
<section class="section">
<h2 class="section-title">${escapeHtml(meta.title)} <span class="count">${grouped[key].length}</span></h2>
<div class="grid">${cards}</div>
</section>`;
})
.join("");
statsEl.textContent = `${SERVICES.length} services · ${grouped.internal?.length || 0} internal · ${
grouped.external?.length || 0
} external`;
bindCopyButtons();
bindOpenWebUIChat();
}
function bindCopyButtons() {
app.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const url = btn.getAttribute("data-copy");
try {
await navigator.clipboard.writeText(url);
} catch {
const ta = document.createElement("textarea");
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
const original = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
setTimeout(() => (btn.innerHTML = original), 1300);
});
});
}
function populateOpenWebUIModels(models) {
document.querySelectorAll(".chat-model").forEach((select) => {
const current = select.value;
select.innerHTML = models
.map((m) => `<option value="${escapeHtml(m.id)}"${m.id === current ? " selected" : ""}>${escapeHtml(m.name)}</option>`)
.join("");
if (!select.value && models[0]) select.value = models[0].id;
});
}
function bindOpenWebUIChat() {
document.querySelectorAll(".card-chat").forEach((container) => {
const select = container.querySelector(".chat-model");
const input = container.querySelector(".chat-prompt");
const sendBtn = container.querySelector(".chat-send");
const responseEl = container.querySelector(".chat-response");
async function send() {
const model = select?.value;
const prompt = input?.value.trim();
if (!prompt) return;
if (!model) {
responseEl.textContent = "No model available — set OPENWEBUI_TOKEN on the container.";
responseEl.classList.add("error");
return;
}
responseEl.textContent = "Thinking…";
responseEl.classList.remove("error");
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
responseEl.textContent = data.message || "(no response)";
} catch (e) {
responseEl.textContent = e.message;
responseEl.classList.add("error");
}
}
sendBtn?.addEventListener("click", send);
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
});
});
}
// ---------------------------------------------------------------------------
// Service health badges (server-side checks via health.json)
// ---------------------------------------------------------------------------
function applyHealth(health) {
const services = health?.services || {};
document.querySelectorAll(".status[data-status-url]").forEach((badge) => {
const url = badge.getAttribute("data-status-url");
const h = services[url];
const text = badge.querySelector(".status-text");
if (!text) return;
if (!h) {
badge.className = "status unknown";
text.textContent = "Unknown";
badge.title = "No health data yet";
return;
}
if (h.status === "online") {
badge.className = "status online";
text.textContent = "Online";
badge.title = h.latencyMs != null ? `${h.latencyMs} ms` : "";
} else if (h.status === "error") {
badge.className = "status error";
text.textContent = "Error";
badge.title = `HTTP ${h.code}`;
} else {
badge.className = "status offline";
text.textContent = "Offline";
badge.title = h.error || "Unreachable";
}
});
}
async function loadHealth() {
try {
const res = await fetch("health.json", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
applyHealth(await res.json());
} catch {
// keep the current badges; the next poll will retry
}
}
// ---------------------------------------------------------------------------
// Outlook unread mail (via /outlook.json, refreshed every minute server-side)
// ---------------------------------------------------------------------------
function relTime(iso) {
const t = new Date(iso).getTime();
if (!t) return "";
const mins = Math.floor((Date.now() - t) / 60000);
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function renderOutlookWidget(o) {
const widget = document.getElementById("widget-outlook");
const el = document.getElementById("outlook-mail");
if (!widget || !el) return;
// Show the widget only once the mailbox is actually authorized; hide it
// while not configured, waiting for (admin) consent or on auth errors.
const authorized = Boolean(o && o.enabled && !o.error);
widget.hidden = !authorized;
if (!authorized) return;
const account = o.account ? `<div class="mail-account">${escapeHtml(o.account)}</div>` : "";
const badge = `<span class="git-badge mail">${o.unreadCount ?? 0} unread</span>`;
const msgs = o.messages || [];
if (msgs.length === 0) {
el.innerHTML = `${account}${badge}<p class="widget-empty">No unread mail.</p>`;
return;
}
el.innerHTML =
`${account}${badge}` +
msgs
.map(
(m) => `
<div class="mail-row">
<div class="mail-head">
<span class="mail-from" title="${escapeHtml(m.from || "")}">${escapeHtml(m.from || "unknown")}</span>
<span class="mail-time">${escapeHtml(relTime(m.received))}</span>
</div>
<div class="mail-subject" title="${escapeHtml(m.subject || "")}">${escapeHtml(m.subject || "(no subject)")}</div>
</div>`
)
.join("");
}
async function loadOutlook() {
try {
const res = await fetch("outlook.json", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderOutlookWidget(await res.json());
} catch {
// keep the current content; the next poll will retry
}
}
// ---------------------------------------------------------------------------
// Search / filter
// ---------------------------------------------------------------------------
function filter() {
const q = searchInput.value.trim().toLowerCase();
const cards = app.querySelectorAll(".card");
let visible = 0;
cards.forEach((card) => {
const match = !q || card.getAttribute("data-search").includes(q);
card.classList.toggle("hidden", !match);
if (match) visible++;
});
// hide sections with no visible cards
app.querySelectorAll(".section").forEach((sec) => {
const hasVisible = sec.querySelector(".card:not(.hidden)");
sec.style.display = hasVisible ? "" : "none";
});
let noRes = document.querySelector(".no-results");
if (visible === 0) {
if (!noRes) {
noRes = document.createElement("div");
noRes.className = "no-results";
noRes.textContent = "No services match your search.";
app.appendChild(noRes);
}
} else if (noRes) {
noRes.remove();
}
}
// ---------------------------------------------------------------------------
// Clock
// ---------------------------------------------------------------------------
function tickClock() {
const now = new Date();
const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const date = now.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short", year: "numeric" });
document.getElementById("clock-time").textContent = time;
document.getElementById("clock-date").textContent = date;
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
render();
tickClock();
setInterval(tickClock, 1000);
searchInput.addEventListener("input", filter);
loadData();
setInterval(loadData, 5 * 60 * 1000); // refresh data every 5 min
loadGpuStats();
setInterval(loadGpuStats, 5000); // live GPU stats every 5 s
loadOllamaStats();
setInterval(loadOllamaStats, 5000); // live Ollama loaded models every 5 s
loadHealth();
setInterval(loadHealth, 30000); // service health badges every 30 s
loadOutlook();
setInterval(loadOutlook, 60000); // unread mail every 60 s