From e766fcd95cfc2c21e218fef60d7b74b82cc84476 Mon Sep 17 00:00:00 2001 From: fegger Date: Fri, 4 Sep 2026 14:07:22 +0200 Subject: [PATCH] Add themed widgets, calendar, and Open WebUI chat to the dashboard - Apply GTK-like color scheme (dark greys, #f76b2f accent) across cards, buttons, glows and category colors - Service cards use the official logo icons; Netbird card shows live reachability + latency instead of a separate top widget - Top widgets: Today (calendar events with in-progress highlighting), Git activity (3 rows visible, scrollable), GPU with live ROCm stats and loaded Ollama models incl. GPU/CPU split - Open WebUI card: inline prompt line with model selection - Calendar section grouping events by day below the services - data.json fetch with boot retry; GPU/Ollama poll every 5 s - Today entries wrap long titles; README documents widgets, environment variables and the .env secrets pattern --- README.md | 51 ++++- index.html | 50 +++++ script.js | 464 ++++++++++++++++++++++++++++++++++++-- style.css | 639 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 1158 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7ab4e93..bf3c236 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Homelab Dashboard A small, self-contained single-page dashboard that links all your homelab services. -Static site (HTML + CSS + JS), served by a lightweight **nginx** image. No backend, no -database, no build step — just static files. +Static site (HTML + CSS + JS), served by a lightweight **nginx** image. The optional +calendar widget needs the container backend to refresh Google/ICS feeds, but the +rest of the dashboard is pure static files. ## Services @@ -13,10 +14,18 @@ External: gitlab-ixsol, gem360. ## Features +- Top widgets: **Today** (calendar events for today, with in-progress highlighting), + **Netbird** status (reachability + latency), **Git activity** (recent commits from + Gitea and GitLab — 3 rows visible, scroll for more), **GPU + Ollama** (live AMD + ROCm stats: utilization, VRAM, temperature, power; plus loaded Ollama models + with their GPU/CPU split) +- Inline **Open WebUI prompt line with model selection** in the Open WebUI card + (requires `OPENWEBUI_TOKEN`) - Responsive grid of service cards with icons, categories and a live "Online" indicator - **Open** (new tab) + **Copy URL** per service - Live search / filter across names, categories and URLs - Live local clock +- Calendar section aggregating multiple ICS feeds (refreshed every 15 min in the container) - Dark, themeable design; respects `prefers-reduced-motion` ## Run with Docker @@ -27,9 +36,43 @@ docker compose up -d --build Then open . +The dashboard is static, but the widgets are populated by a small Node.js updater +(`api/update-data.js`) that runs inside the container and aggregates ICS feeds, +Netbird status, git activity and Open WebUI models every 15 minutes. A second +Node process (`api/server.js`) proxies chat prompts to Open WebUI. + +### Environment variables + +Secrets (API keys) live in **`.env`** (gitignored) — docker compose reads it +automatically. Non-secret settings go directly in `docker-compose.yml`: + +| Variable | Purpose | +| --- | --- | +| `TZ` | Timezone used for the "Today" widget (e.g. `Europe/Vienna`) | +| `OPENWEBUI_TOKEN` | Open WebUI API key — enables the prompt line + model list (**set in `.env`**) | +| `GITEA_TOKEN` | Gitea API token — private repo stats (public repos work without it) | +| `GITLAB_TOKEN` | GitLab API token — private project stats (public projects work without it) | +| `GITEA_URL` / `GITLAB_URL` / `NETBIRD_URL` / `OPENWEBUI_URL` | Override the default service URLs | +| `GPU_STATS_URL` | GPU stats upstream (default: the `gpu-stats` compose service) | +| `OLLAMA_URL` | Ollama instance for loaded-model stats (default: `http://100.103.83.12:11435`) | + +### GPU stats service + +The `gpu-stats` compose service runs [`gpu_stats.py`](./gpu_stats.py), a tiny HTTP +server that queries `rocm-smi`. It needs the host's ROCm install and GPU device +nodes, so the compose file mounts `/opt/rocm` and passes `/dev/kfd` + `/dev/dri`. +Set `GPU_NAME` in `docker-compose.yml` to your card's name (rocm-smi can't read it +inside the container without `libdrm_amdgpu`). The widget polls it every 5 s +through `/api/gpu-stats`. + +To run it on the host instead: `python3 gpu_stats.py`, set +`GPU_STATS_URL=http://host.docker.internal:9101/stats` on the dashboard service +and add `extra_hosts: ["host.docker.internal:host-gateway"]`. + ### Without Docker (quick dev) -Any static file server works: +Any static file server works for the dashboard itself, but the widgets will +show errors because the data fetcher and chat proxy run server-side: ```bash # from this directory @@ -61,4 +104,6 @@ docker run -d --name homelab-dashboard -p 8888:80 --restart unless-stopped homel - The "Online" badge is cosmetic (a static site can't probe your services from the browser due to CORS). If you want real health checks later, a tiny backend that pings each URL would be the next step. +- Calendar dependencies are bundled in `api/node_modules` so the Docker build works + offline. To update them, run `npm install` in the `api/` directory. - Port 8888 is published by the Compose file; change it there if needed. diff --git a/index.html b/index.html index 4e58055..1f0ceae 100644 --- a/index.html +++ b/index.html @@ -51,10 +51,60 @@ +
+
+
+ + Today +
+
+

Loading…

+
+
+ +
+
+ + Git activity +
+
+

Loading…

+
+
+ +
+
+ + GPU +
+
+
+

Loading…

+
+
+
Ollama
+
+

Loading…

+
+
+
+
+
+
+
+

+ Calendar + 0 +

+
+

Loading calendar…

+
+
+
diff --git a/script.js b/script.js index 9baef11..5caca0c 100644 --- a/script.js +++ b/script.js @@ -5,40 +5,41 @@ // 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) +// icon : an emoji (or any short text), or HTML such as +// '' // --------------------------------------------------------------------------- const CATEGORIES = { - ai: { label: "AI", color: "#a855f7" }, - dev: { label: "Development", color: "#3b82f6" }, - media: { label: "Media", color: "#ec4899" }, - photos: { label: "Photos", color: "#14b8a6" }, - smartHome: { label: "Smart Home", color: "#10b981" }, - audio: { label: "Audio", color: "#f97316" }, - storage: { label: "Storage", color: "#6366f1" }, - security: { label: "Security", color: "#f43f5e" }, - infra: { label: "Infrastructure", color: "#f59e0b" }, - network: { label: "Networking", color: "#0ea5e9" }, - external: { label: "External", color: "#22d3ee" }, + 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: "🤖" }, - { section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: "🎨" }, - { section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: "🐙" }, - { section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: "🎬" }, - { section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: "📸" }, - { section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: "🏠" }, - { section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: "🎧" }, - { section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: "☁️" }, - { section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: "🔐" }, + { section: "internal", name: "Open WebUI", url: "http://100.103.83.12:3000", cat: "ai", icon: '', chat: true }, + { section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: '' }, + { section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: '' }, + { section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: '' }, + { section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: '' }, + { section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: '' }, + { section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: '' }, + { section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: '' }, + { section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: '' }, { 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: "🛰️" }, + { section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: '', liveStatus: "netbird" }, // ----- External ----- - { section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: "🐙" }, - { section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: "🎮" }, + { section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '' }, + { section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '' }, ]; const SECTION_META = { @@ -46,6 +47,342 @@ const SECTION_META = { 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 = `

${msg}

`; + document.getElementById("git-stats").innerHTML = `

${msg}

`; + const nbEl = document.querySelector('[data-live-status="netbird"]'); + if (nbEl) nbEl.innerHTML = `${msg}`; + const calendarEl = document.getElementById("calendar"); + calendarEl.innerHTML = `

Data unavailable: ${msg}

`; + 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 = ``; + }); + } +} + +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 = `

No upcoming events.

`; + countEl.textContent = "0"; + return; + } + + const grouped = groupEventsByDay(upcoming); + + calendarEl.innerHTML = Object.entries(grouped) + .map(([day, events]) => { + return ` +
+
${escapeHtml(day)}
+
+ ${events.map((e) => renderEvent(e, calMap[e.calendarId])).join("")} +
+
`; + }) + .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 ? `
📍 ${escapeHtml(e.location)}
` : ""; + const description = e.description + ? `
${escapeHtml(e.description.split("\n")[0])}
` + : ""; + + return ` +
+ +
+
${escapeHtml(timeText)}
+
${escapeHtml(e.summary)}
+ ${location} + ${description} +
+
`; +} + +// --------------------------------------------------------------------------- +// 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 = `

No events today.

`; + 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 ` +
+ + ${escapeHtml(time)} + ${escapeHtml(e.summary)} +
`; + }) + .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 = `Status unavailable`; + return; + } + + const online = status.status === "online"; + const dotClass = online ? "online" : "offline"; + const text = online ? "Reachable" : status.error || "Unreachable"; + const latency = status.latencyMs ? ` · ${status.latencyMs} ms` : ""; + + el.innerHTML = ` + + ${escapeHtml(text)}${latency}`; +} + +function renderGitWidget(git) { + const el = document.getElementById("git-stats"); + if (!git) { + el.innerHTML = `

Git stats unavailable.

`; + return; + } + + const gitea = git.gitea || { repos: 0, recentCommits: [], error: null }; + const gitlab = git.gitlab || { projects: 0, recentCommits: [], error: null }; + + const giteaError = gitea.error ? `
Gitea: ${escapeHtml(gitea.error)}
` : ""; + const gitlabError = gitlab.error ? `
GitLab: ${escapeHtml(gitlab.error)}
` : ""; + + 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 = `
No recent commits visible.
`; + } else { + // The list is capped to ~3 visible rows via CSS (max-height) and scrolls. + commitsHtml = ``; + } + + el.innerHTML = ` +
+ Gitea ${gitea.repos || 0} + GitLab ${gitlab.projects || 0} + Commits ${gitea.recentCommits.length + gitlab.recentCommits.length} +
+ ${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 = `

${escapeHtml(g?.error || "GPU stats unavailable")}

`; + 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 = ` +
${escapeHtml(g.name || "GPU")}
+
+ Utilization + ${util}% +
+
+
+ VRAM + ${gb(g.memory_used || 0)} / ${gb(g.memory_total || 0)} GB +
+
+
+ ${temp != null ? `${temp}°C` : ""} + ${g.power_w != null ? `${Math.round(g.power_w)} W` : ""} +
`; +} + +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 = + `

${escapeHtml(err.message)}

`; + } +} + +// --------------------------------------------------------------------------- +// 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 = `
No model loaded
`; + 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 ` +
+
+ ${escapeHtml(name)} + ${gib(size)} +
+
+ GPU ${gpuPct}% · CPU ${cpuPct}% +
+
`; + }) + .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 = + `

${escapeHtml(err.message)}

`; + } +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -66,6 +403,22 @@ function escapeHtml(str) { 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 + ? `
+
+ + + +
+
+
` + : ""; return ` `; } @@ -122,6 +477,7 @@ function render() { } external`; bindCopyButtons(); + bindOpenWebUIChat(); } function bindCopyButtons() { @@ -145,6 +501,60 @@ function bindCopyButtons() { }); } +function populateOpenWebUIModels(models) { + document.querySelectorAll(".chat-model").forEach((select) => { + const current = select.value; + select.innerHTML = models + .map((m) => ``) + .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(); + } + }); + }); +} + // --------------------------------------------------------------------------- // Search / filter // --------------------------------------------------------------------------- @@ -198,3 +608,9 @@ 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 diff --git a/style.css b/style.css index 04081c2..ced6d2d 100644 --- a/style.css +++ b/style.css @@ -1,16 +1,16 @@ :root { - --bg: #070b14; - --bg-soft: #0b1220; + --bg: #161719; + --bg-soft: #1b1c1e; --surface: rgba(255, 255, 255, 0.035); - --surface-hover: rgba(255, 255, 255, 0.06); + --surface-hover: #232527; --border: rgba(255, 255, 255, 0.09); --border-hover: rgba(255, 255, 255, 0.18); - --text: #e7ecf5; - --muted: #8a94a8; - --muted-2: #636e84; - --accent: #22d3ee; - --accent-2: #a855f7; - --online: #34d399; + --text: #d6d8da; + --muted: #8c8f94; + --muted-2: #6b6e73; + --accent: #f76b2f; + --accent-2: #5c8ad8; + --online: #8ab06a; --radius: 18px; --radius-sm: 12px; --shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.7); @@ -43,9 +43,9 @@ body { z-index: 0; pointer-events: none; background: - radial-gradient(60% 50% at 12% -5%, rgba(34, 211, 238, 0.14), transparent 60%), - radial-gradient(55% 45% at 95% 0%, rgba(168, 85, 247, 0.14), transparent 60%), - radial-gradient(70% 60% at 50% 120%, rgba(52, 211, 153, 0.08), transparent 60%); + radial-gradient(60% 50% at 12% -5%, rgba(247, 107, 47, 0.12), transparent 60%), + radial-gradient(55% 45% at 95% 0%, rgba(92, 138, 216, 0.12), transparent 60%), + radial-gradient(70% 60% at 50% 120%, rgba(138, 176, 106, 0.07), transparent 60%); } .shell { @@ -77,7 +77,7 @@ body { height: 46px; border-radius: 14px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); - box-shadow: 0 10px 30px -8px rgba(34, 211, 238, 0.6); + box-shadow: 0 10px 30px -8px rgba(247, 107, 47, 0.5); position: relative; flex: none; } @@ -277,6 +277,12 @@ main#app { flex: none; } +.card-icon img { + width: 28px; + height: 28px; + display: block; +} + .status { display: inline-flex; align-items: center; @@ -296,17 +302,17 @@ main#app { height: 8px; border-radius: 50%; background: var(--online); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.18); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--online) 18%, transparent); animation: pulse 2.4s ease-in-out infinite; } @keyframes pulse { 0%, 100% { - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.18); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--online) 18%, transparent); } 50% { - box-shadow: 0 0 0 5px rgba(52, 211, 153, 0.05); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--online) 5%, transparent); } } @@ -379,13 +385,13 @@ main#app { } .btn-primary { - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: linear-gradient(135deg, var(--accent), #c8531f); border-color: transparent; - color: #05070d; + color: #1b1c1e; } .btn-primary:hover { - background: linear-gradient(135deg, #38dbf2, #b56bff); + background: linear-gradient(135deg, #ff8a52, #f76b2f); } .btn-ghost { @@ -405,6 +411,103 @@ main#app { font-size: 14px; } +.card-chat { + margin-top: 10px; + padding-top: 12px; + border-top: 1px solid var(--border); + display: grid; + gap: 8px; +} + +.chat-row { + display: flex; + gap: 8px; +} + +.chat-model { + flex: 1 1 0; + min-width: 0; + background: var(--bg-soft); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + font: inherit; + font-size: 12.5px; + outline: none; +} + +.chat-model:focus { + border-color: var(--border-hover); +} + +.chat-prompt { + flex: 2 1 0; + min-width: 0; + background: var(--bg-soft); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + font: inherit; + font-size: 13px; + outline: none; +} + +.chat-prompt::placeholder { + color: var(--muted-2); +} + +.chat-prompt:focus { + border-color: var(--border-hover); +} + +.chat-send { + flex: none; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent), #c8531f); + color: #1b1c1e; + cursor: pointer; + transition: filter 0.15s ease; +} + +.chat-send:hover { + filter: brightness(1.1); +} + +.chat-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-response { + font-size: 13px; + line-height: 1.4; + color: var(--text); + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + max-height: 180px; + overflow-y: auto; + white-space: pre-wrap; +} + +.chat-response:empty { + display: none; +} + +.chat-response.error { + color: var(--error_color, #e0554a); + border-color: color-mix(in srgb, var(--error_color, #e0554a) 40%, transparent); +} + +/* ---------- footer ---------- */ .footer { margin-top: 40px; text-align: center; @@ -412,6 +515,494 @@ main#app { font-size: 13px; } +/* ---------- calendar ---------- */ +.calendar-section { + margin-top: 34px; +} + +.calendar { + display: grid; + gap: 18px; +} + +.calendar-day { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.calendar-day-header { + padding: 12px 16px; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--border); +} + +.calendar-events { + display: grid; + padding: 12px; + gap: 10px; +} + +.calendar-event { + display: flex; + gap: 14px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + transition: + background 0.18s ease, + border-color 0.18s ease, + transform 0.12s ease; +} + +.calendar-event:hover { + background: var(--surface-hover); + border-color: var(--border-hover); + transform: translateX(4px); +} + +.event-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin-top: 5px; + flex: none; + background: var(--cal, var(--accent)); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--cal, var(--accent)) 22%, transparent); +} + +.event-body { + display: grid; + gap: 4px; + min-width: 0; +} + +.event-time { + font-size: 12px; + font-weight: 600; + color: color-mix(in srgb, var(--cal, var(--accent)) 80%, white); +} + +.event-title { + font-size: 14.5px; + font-weight: 600; + color: var(--text); + word-break: break-word; +} + +.event-location, +.event-description { + font-size: 12px; + color: var(--muted); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ---------- empty / footer ---------- */ +.calendar-loading, +.calendar-empty, +.calendar-error { + color: var(--muted); + font-size: 14px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.calendar-error { + color: var(--error_color, #e0554a); +} + +/* ---------- widgets ---------- */ +.widgets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-bottom: 34px; +} + +.widget { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + transition: + transform 0.18s ease, + border-color 0.2s ease, + background 0.2s ease; +} + +.widget:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.widget-head { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 14px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.widget-icon { + width: 30px; + height: 30px; + border-radius: 8px; + display: grid; + place-items: center; + background: var(--surface-hover); + border: 1px solid var(--border); + font-size: 15px; +} + +.widget-body { + display: grid; + gap: 8px; +} + +.widget-empty, +.widget-error, +.widget-loading { + color: var(--muted); + font-size: 13px; + margin: 0; +} + +.widget-error { + color: var(--error_color, #e0554a); +} + +/* today widget */ +.today-event { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 13.5px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.today-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; + /* align with the first text line when summaries wrap */ + margin-top: 6px; + background: var(--cal, var(--accent)); +} + +.today-time { + flex: none; + font-weight: 600; + color: var(--muted-2); + min-width: 48px; +} + +.today-summary { + color: var(--text); + overflow-wrap: anywhere; +} + +.today-event.past { + opacity: 0.45; +} + +.today-event.now .today-time { + color: var(--accent); +} + +.today-event.now .today-summary::after { + content: " · now"; + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +/* netbird live status (inside the Netbird service card) */ +.card-live-status { + display: flex; + align-items: center; + gap: 9px; + font-size: 12.5px; + color: var(--muted); + padding-top: 2px; +} + +.netbird-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; + background: var(--warning_color, #f2a944); +} + +.netbird-dot.online { + background: var(--success_color, #8ab06a); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--success_color, #8ab06a) 18%, transparent); + animation: pulse 2.4s ease-in-out infinite; +} + +.netbird-dot.offline { + background: var(--error_color, #e0554a); +} + +.netbird-text { + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.netbird-latency { + color: var(--muted-2); + font-variant-numeric: tabular-nums; +} + +/* git widget */ +.git-summary { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.git-badge { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 4px 8px; + border-radius: 6px; + background: var(--surface-hover); + border: 1px solid var(--border); + color: var(--muted); +} + +.git-badge.gitea { + color: var(--link_color, #5c8ad8); + border-color: color-mix(in srgb, var(--link_color, #5c8ad8) 40%, transparent); +} + +.git-badge.gitlab { + color: var(--warning_color, #f2a944); + border-color: color-mix(in srgb, var(--warning_color, #f2a944) 40%, transparent); +} + +.git-badge.total { + color: var(--success_color, #8ab06a); + border-color: color-mix(in srgb, var(--success_color, #8ab06a) 40%, transparent); +} + +.git-error { + font-size: 12px; + color: var(--error_color, #e0554a); +} + +.git-commits { + list-style: none; + margin: 0; + padding: 0 4px 0 0; + display: grid; + gap: 8px; + /* show ~3 commit rows, scroll for the rest */ + max-height: 216px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #4c4f54 transparent; +} + +.git-commits::-webkit-scrollbar { + width: 6px; +} + +.git-commits::-webkit-scrollbar-thumb { + background: #4c4f54; + border-radius: 3px; +} + +.git-commits::-webkit-scrollbar-track { + background: transparent; +} + +.git-commit { + display: grid; + gap: 2px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + font-size: 12.5px; +} + +.git-repo { + font-weight: 700; + color: var(--accent); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.git-msg { + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.git-meta { + color: var(--muted-2); + font-size: 11px; +} + +/* gpu widget */ +.gpu-name { + font-size: 13px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gpu-row { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 12px; + color: var(--muted); +} + +.gpu-val { + color: var(--text); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.gpu-bar { + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.gpu-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.6s ease; +} + +.gpu-bar-fill.util { + background: linear-gradient(90deg, var(--accent), #c8531f); +} + +.gpu-bar-fill.mem { + background: linear-gradient(90deg, var(--accent-2), #3a6db8); +} + +.gpu-meta { + display: flex; + gap: 14px; + font-size: 12px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.gpu-temp { + color: var(--success_color, #8ab06a); +} + +.gpu-temp.warm { + color: var(--warning_color, #f2a944); +} + +.gpu-temp.hot { + color: var(--error_color, #e0554a); +} + +/* ollama block inside the gpu widget */ +.ollama-block { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--border); + display: grid; + gap: 8px; +} + +.ollama-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-2); +} + +.ollama-model { + display: grid; + gap: 3px; + padding: 7px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.ollama-model-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 12.5px; +} + +.ollama-name { + color: var(--text); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ollama-size { + color: var(--muted-2); + font-variant-numeric: tabular-nums; + flex: none; +} + +.ollama-split { + font-size: 11.5px; + color: var(--muted-2); + font-variant-numeric: tabular-nums; +} + +.ollama-gpu { + color: var(--accent); + font-weight: 700; +} + +.ollama-cpu { + color: var(--accent-2); + font-weight: 700; +} + +/* ---------- empty / footer ---------- */ + /* ---------- responsive ---------- */ @media (max-width: 640px) { .topbar { @@ -431,6 +1022,16 @@ main#app { #clock-time { font-size: 18px; } + .widgets { + grid-template-columns: 1fr; + } + .chat-row { + flex-wrap: wrap; + } + .chat-model, + .chat-prompt { + flex: 1 1 100%; + } } @media (prefers-reduced-motion: reduce) {