feat(dashboard): service health widget, Cmd+K palette, CI runs, pinned shortcuts
Deploy Dashboard (Dev) / Backend Tests (push) Successful in 1m15s
Deploy Dashboard (Dev) / Frontend Tests (push) Successful in 55s
Deploy Dashboard (Dev) / Build Dev Image (push) Failing after 13m49s
Deploy Dashboard (Dev) / Deploy to Dev (push) Has been skipped

This commit is contained in:
Gan, Jimmy
2026-07-09 03:36:31 +08:00
parent b2c4e73e4a
commit bfa11b5f26
4 changed files with 434 additions and 59 deletions
+57 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import os import os
import platform import platform
import shutil import shutil
@@ -9,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
import auth_service as auth import auth_service as auth
import config import config
from config import OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT from config import GITEA_TOKEN, GITEA_URL, OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT
router = APIRouter() router = APIRouter()
@@ -151,6 +152,61 @@ def openclaw_token(request: Request):
return {"token": OPENCLAW_GATEWAY_TOKEN} return {"token": OPENCLAW_GATEWAY_TOKEN}
@router.get("/service-health")
async def service_health(current_user=Depends(auth.get_current_user)):
"""Ping all external services and return their status with latency."""
async def _ping(key: str, url: str) -> dict:
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.head(url, follow_redirects=True)
latency_ms = round((time.monotonic() - start) * 1000)
status = "up" if r.status_code < 500 else "down"
except httpx.TimeoutException:
latency_ms = round((time.monotonic() - start) * 1000)
status = "down"
except Exception:
latency_ms = round((time.monotonic() - start) * 1000)
status = "error"
return key, {"url": url, "status": status, "latency_ms": latency_ms}
results = await asyncio.gather(
*(_ping(key, url) for key, url in config.EXTERNAL_SERVICES.items())
)
return {"services": dict(results)}
_CI_HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
@router.get("/ci-runs")
async def ci_runs():
"""Fetch recent Gitea Actions CI runs for jimmy/nas-tools."""
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"{GITEA_URL}/api/v1/repos/jimmy/nas-tools/actions/runs",
headers=_CI_HEADERS,
params={"limit": 5},
)
r.raise_for_status()
data = r.json()
runs = []
for wf in data.get("workflow_runs", []):
runs.append(
{
"id": wf.get("id"),
"status": wf.get("status"),
"event": wf.get("event"),
"head_branch": wf.get("head_branch"),
"head_sha": (wf.get("head_sha") or "")[:12],
"display_title": wf.get("display_title"),
"run_number": wf.get("run_number"),
}
)
return {"workflow_runs": runs}
@router.get("/external-services") @router.get("/external-services")
def external_services(): def external_services():
"""Return external service URLs and network config for the frontend sidebar.""" """Return external service URLs and network config for the frontend sidebar."""
+32
View File
@@ -1,5 +1,6 @@
<script> <script>
import Sidebar from "./components/Sidebar.svelte"; import Sidebar from "./components/Sidebar.svelte";
import CommandPalette from "./components/CommandPalette.svelte";
import Dashboard from "./routes/Dashboard.svelte"; import Dashboard from "./routes/Dashboard.svelte";
import Gitea from "./routes/Gitea.svelte"; import Gitea from "./routes/Gitea.svelte";
import Files from "./routes/Files.svelte"; import Files from "./routes/Files.svelte";
@@ -29,6 +30,36 @@
"transmission", "transmission",
]); ]);
const navItems = [
// Main
{ id: "dashboard", label: "Overview", section: "main", description: "System stats, service health, CI runs" },
{ id: "litellm", label: "LiteLLM", section: "main", description: "LLM proxy and model management" },
{ id: "opc", label: "OPC", section: "main", description: "Open Project Console — kanban and agents" },
{ id: "files", label: "Files", section: "main", description: "NAS file browser" },
{ id: "terminal", label: "Terminal", section: "main", description: "SSH terminal to NAS" },
{ id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" },
// Media
{ id: "transmission", label: "Transmission", section: "media", description: "BitTorrent client" },
{ label: "Navidrome", section: "media", remoteHref: "https://music.jimmygan.com", description: "Music streaming" },
{ label: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" },
{ label: "Audiobookshelf", section: "media", remoteHref: "https://books.jimmygan.com", description: "Audiobooks & podcasts" },
{ label: "Immich", section: "media", remoteHref: "https://photos.jimmygan.com", description: "Photo and video backup" },
{ label: "t-youtube", section: "media", remoteHref: "https://yt.jimmygan.com", description: "YouTube subscription reader" },
{ label: "Media Management", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media library management" },
// Tools
{ id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" },
{ id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" },
{ id: "gitea", label: "Repos", section: "tools", description: "Gitea repository list" },
{ label: "Gitea Web", section: "tools", remoteHref: "https://git.jimmygan.com", description: "Gitea web interface" },
{ label: "Hermes", section: "tools", remoteHref: "https://hermes-agent.nousresearch.com/docs", description: "Hermes Agent docs" },
{ label: "Stirling PDF", section: "tools", remoteHref: "https://pdf.jimmygan.com", description: "PDF tools" },
{ label: "Vaultwarden", section: "tools", remoteHref: "https://vault.jimmygan.com", description: "Password manager" },
{ label: "n8n", section: "tools", remoteHref: "https://n8n.jimmygan.com", description: "Workflow automation" },
{ label: "Speedtest", section: "tools", remoteHref: "https://speed.jimmygan.com", description: "Network speed test" },
// Settings
{ id: "settings", label: "Settings", section: "tools", description: "Dashboard configuration" },
];
let page = $state("dashboard"); let page = $state("dashboard");
let dark = $state(false); let dark = $state(false);
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
@@ -177,6 +208,7 @@
{:else} {:else}
<div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900"> <div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {allowedSidebarLinks} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} /> <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {allowedSidebarLinks} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
<CommandPalette items={navItems} onNavigate={(id) => page = id} />
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
<div class="max-w-[1400px] mx-auto p-6 lg:p-8"> <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
@@ -0,0 +1,138 @@
<script>
let { items = [], onNavigate = () => {}, onOpenExternal = () => {} } = $props();
let open = $state(false);
let query = $state("");
let selectedIndex = $state(0);
let inputEl = $state(null);
function fuzzyMatch(text, q) {
if (!q) return true;
const lower = text.toLowerCase();
const ql = q.toLowerCase();
let qi = 0;
for (let i = 0; i < lower.length && qi < ql.length; i++) {
if (lower[i] === ql[qi]) qi++;
}
return qi === ql.length;
}
let filtered = $derived(
items.filter(i => fuzzyMatch(i.label + " " + (i.section || ""), query))
);
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
open = !open;
if (open) {
query = "";
selectedIndex = 0;
}
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === "Enter" && filtered[selectedIndex]) {
e.preventDefault();
select(filtered[selectedIndex]);
} else if (e.key === "Escape") {
e.preventDefault();
open = false;
}
}
function select(item) {
open = false;
if (item.external || item.remoteHref) {
window.open(item.remoteHref || item.href, "_blank", "noopener");
} else if (item.id) {
onNavigate(item.id);
}
}
function sectionClass(section) {
if (section === "main") return "text-sky-500";
if (section === "media") return "text-violet-500";
if (section === "tools") return "text-amber-500";
return "text-surface-400";
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Overlay -->
<button class="fixed inset-0 bg-black/40 z-[100] backdrop-blur-sm" onclick={() => open = false}></button>
<!-- Palette -->
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[101] w-[520px] max-w-[90vw]" role="dialog" aria-modal="true">
<div class="bg-white dark:bg-surface-800 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-2xl overflow-hidden">
<!-- Search -->
<div class="flex items-center px-4 border-b border-surface-200 dark:border-surface-700">
<svg class="w-4 h-4 text-surface-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input
bind:this={inputEl}
type="text"
placeholder="Search services, pages, actions..."
bind:value={query}
class="w-full bg-transparent border-none outline-none px-3 py-4 text-sm text-surface-800 dark:text-surface-200 placeholder-surface-400"
autofocus
/>
<kbd class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-400">esc</kbd>
</div>
<!-- Results -->
<div class="max-h-[360px] overflow-y-auto p-2">
{#if filtered.length === 0}
<div class="py-8 text-center text-sm text-surface-400">No results found</div>
{:else}
{#each filtered as item, i}
<button
class="w-full text-left px-3 py-2.5 rounded-xl text-sm flex items-center gap-3 transition-all duration-100
{i === selectedIndex
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700'}
"
onmouseenter={() => selectedIndex = i}
onclick={() => select(item)}
>
<span class="w-6 h-6 rounded-lg flex items-center justify-center text-xs font-bold uppercase {sectionClass(item.section)}">
{item.section ? item.section[0] : "?"}
</span>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{item.label}</div>
{#if item.description}
<div class="text-[11px] text-surface-400 truncate">{item.description}</div>
{/if}
</div>
{#if item.external || item.remoteHref}
<svg class="w-3 h-3 text-surface-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
{/if}
</button>
{/each}
{/if}
</div>
<!-- Footer hint -->
<div class="flex items-center gap-4 px-4 py-2.5 border-t border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800/50">
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500">↑↓</kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500"></kbd>
<span>Open</span>
</div>
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500">esc</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
{/if}
+197 -48
View File
@@ -1,23 +1,31 @@
<script> <script>
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { get } from "../lib/api.js"; import { get, getPreferences, savePreferences } from "../lib/api.js";
let containers = $state([]);
let repos = $state([]);
let stats = $state(null); let stats = $state(null);
let services = $state([]);
let ciRuns = $state([]);
let loading = $state(true); let loading = $state(true);
let serviceLoading = $state(true);
let ciLoading = $state(true);
let pinned = $state([]);
let editPinned = $state(false);
async function load() { // Nav items for pinning
const [c, g, s] = await Promise.all([ const allNavItems = [
get("/docker/containers"), { id: "litellm", label: "LiteLLM", icon: "bolt" },
get("/gitea/repos"), { id: "opc", label: "OPC", icon: "kanban" },
get("/system/stats"), { id: "files", label: "Files", icon: "folder" },
]); { id: "terminal", label: "Terminal", icon: "terminal" },
containers = c || []; { id: "transmission", label: "Transmission", icon: "download" },
repos = Array.isArray(g) ? g : g?.data || []; { label: "Navidrome", icon: "music", remoteHref: "https://music.jimmygan.com" },
stats = s; { label: "Jellyfin", icon: "play", remoteHref: "https://media.jimmygan.com" },
loading = false; { label: "Immich", icon: "image", remoteHref: "https://photos.jimmygan.com" },
} { label: "Audiobookshelf", icon: "headphones", remoteHref: "https://books.jimmygan.com" },
{ label: "t-youtube", icon: "youtube", remoteHref: "https://yt.jimmygan.com" },
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
{ id: "gitea", label: "Repos", icon: "git" },
];
function fmtBytes(b) { function fmtBytes(b) {
if (!b) return "0 B"; if (!b) return "0 B";
@@ -32,17 +40,99 @@
return "text-emerald-500"; return "text-emerald-500";
} }
function statusColor(status) {
if (status === "up") return "bg-emerald-400";
if (status === "down") return "bg-rose-400";
return "bg-surface-300";
}
function statusLabel(status) {
if (status === "up") return "Up";
if (status === "down") return "Down";
return "Error";
}
function ciStatusBadge(status) {
if (status === "success") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
if (status === "failure") return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
if (status === "in_progress" || status === "running") return "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300";
if (status === "cancelled") return "bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400";
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
}
function openExternal(href) {
if (href) window.open(href, "_blank", "noopener");
}
function togglePin(item) {
const key = item.id || item.label;
if (pinned.some(p => (p.id || p.label) === key)) {
pinned = pinned.filter(p => (p.id || p.label) !== key);
} else {
pinned = [...pinned, item];
}
savePreferences({ pinned: pinned.map(p => p.id || p.label) }).catch(() => {});
}
function isPinned(item) {
const key = item.id || item.label;
return pinned.some(p => (p.id || p.label) === key);
}
let interval; let interval;
onMount(() => { load(); interval = setInterval(load, 10000); }); onMount(async () => {
// Load stats
const [s, prefs] = await Promise.all([
get("/system/stats").catch(() => null),
getPreferences().catch(() => ({})),
]);
stats = s;
loading = false;
// Load pinned from prefs
const savedPins = prefs.pinned || [];
if (savedPins.length > 0) {
pinned = savedPins.map(key => allNavItems.find(i => (i.id || i.label) === key)).filter(Boolean);
} else {
pinned = allNavItems.slice(0, 4);
}
// Load service health
loadServices();
loadCIRuns();
interval = setInterval(() => { loadServices(); loadCIRuns(); }, 30000);
});
onDestroy(() => clearInterval(interval)); onDestroy(() => clearInterval(interval));
async function loadServices() {
const data = await get("/system/service-health").catch(() => null);
if (data?.services) {
services = Object.entries(data.services).map(([key, val]) => ({ key, ...val }));
}
serviceLoading = false;
}
async function loadCIRuns() {
const data = await get("/system/ci-runs").catch(() => null);
ciRuns = data?.workflow_runs || [];
ciLoading = false;
}
</script> </script>
<div class="space-y-8"> <div class="space-y-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Overview</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Overview</h1>
<p class="text-sm text-surface-400 mt-1">System status at a glance</p> <p class="text-sm text-surface-400 mt-1">System status at a glance</p>
</div> </div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-surface-400 hidden sm:inline">Cmd+K to search</span>
<kbd class="hidden sm:inline text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-400 border border-surface-200 dark:border-surface-600">⌘K</kbd>
</div>
</div>
{#if loading} {#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -51,9 +141,47 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<!-- Pinned Shortcuts -->
<div class="flex items-center gap-2 flex-wrap">
{#each pinned as item}
<button
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 hover:bg-primary-50 dark:hover:bg-primary-900/20 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-700 dark:hover:text-primary-300 shadow-sm transition-all duration-150"
onclick={() => item.remoteHref ? openExternal(item.remoteHref) : window.location.href = `/?page=${item.id}`}
>
<span class="w-5 h-5 rounded-md bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center text-[10px] font-bold text-primary-600 dark:text-primary-400">
{item.label[0]}
</span>
{item.label}
</button>
{/each}
<button
class="p-2 rounded-xl text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
onclick={() => editPinned = !editPinned}
title="Edit pinned shortcuts"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</button>
</div>
<!-- Edit pinned panel -->
{#if editPinned}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400 mb-3">Pin your favorite services</p>
<div class="flex flex-wrap gap-2">
{#each allNavItems as item}
<button
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all {isPinned(item) ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300 ring-1 ring-primary-300' : 'bg-surface-100 text-surface-500 dark:bg-surface-700 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'}"
onclick={() => togglePin(item)}
>
{item.label}
</button>
{/each}
</div>
</div>
{/if}
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CPU -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p>
@@ -65,7 +193,6 @@
<p class="text-xs text-surface-400 mt-1">{stats?.cpu_count || 0} cores · Load {stats?.load_avg?.[0] || 0}</p> <p class="text-xs text-surface-400 mt-1">{stats?.cpu_count || 0} cores · Load {stats?.load_avg?.[0] || 0}</p>
</div> </div>
<!-- Memory -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p>
@@ -77,7 +204,6 @@
<p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.memory?.used)} / {fmtBytes(stats?.memory?.total)}</p> <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.memory?.used)} / {fmtBytes(stats?.memory?.total)}</p>
</div> </div>
<!-- Disk -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p>
@@ -89,7 +215,6 @@
<p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.disk?.free)} free of {fmtBytes(stats?.disk?.total)}</p> <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.disk?.free)} free of {fmtBytes(stats?.disk?.total)}</p>
</div> </div>
<!-- Uptime -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p> <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p>
@@ -102,50 +227,74 @@
</div> </div>
</div> </div>
<!-- Services Grid --> <!-- Widget Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Docker Summary --> <!-- Service Health -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm"> <div class="lg:col-span-2 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Docker Containers</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Service Health</h3>
<span class="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">{containers.filter(c => c.status === "running").length}/{containers.length} running</span> <span class="text-xs text-surface-400">{services.filter(s => s.status === "up").length}/{services.length} online</span>
</div> </div>
{#if serviceLoading}
<div class="space-y-2"> <div class="space-y-2">
{#each containers.slice(0, 5) as c} {#each Array(6) as _}
<div class="flex items-center justify-between py-1.5 text-sm"> <div class="h-10 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full {c.status !== 'running' ? 'bg-surface-300' : c.health === 'unhealthy' ? 'bg-rose-400' : c.health === 'healthy' ? 'bg-emerald-400' : 'bg-sky-400'}"></span>
<span class="font-medium text-surface-700 dark:text-surface-200">{c.name}</span>
</div>
<span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span>
</div>
{/each} {/each}
{#if containers.length > 5}
<p class="text-xs text-primary-500 font-medium pt-1">+{containers.length - 5} more</p>
{/if}
</div> </div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{#each services as svc}
<a
href={svc.url}
target="_blank"
rel="noopener"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-all group"
>
<span class="w-2 h-2 rounded-full shrink-0 {statusColor(svc.status)}"></span>
<span class="flex-1 font-medium text-surface-700 dark:text-surface-200 truncate">{svc.key.replace(/_/g, " ")}</span>
<span class="text-xs font-medium {svc.status === 'up' ? 'text-emerald-600' : svc.status === 'down' ? 'text-rose-500' : 'text-surface-400'}">{statusLabel(svc.status)}</span>
{#if svc.latency_ms != null}
<span class="text-[10px] text-surface-400 font-mono">{svc.latency_ms}ms</span>
{/if}
<svg class="w-3 h-3 text-surface-300 group-hover:text-surface-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
{/each}
</div>
{/if}
</div> </div>
<!-- Repos Summary --> <!-- CI Runs -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Git Repositories</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">CI Runs</h3>
<span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span> <span class="text-xs text-surface-400">nas-tools</span>
</div> </div>
{#if ciLoading}
<div class="space-y-2"> <div class="space-y-2">
{#each repos.slice(0, 5) as r} {#each Array(4) as _}
<div class="flex items-center justify-between py-1.5 text-sm"> <div class="h-14 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
<div class="flex items-center gap-2"> {/each}
<svg class="w-3.5 h-3.5 text-surface-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
<span class="font-medium text-surface-700 dark:text-surface-200">{r.name}</span>
</div> </div>
<span class="text-xs text-surface-400">{r.language || "—"}</span> {:else if ciRuns.length === 0}
<div class="py-8 text-center text-sm text-surface-400">No CI runs found</div>
{:else}
<div class="space-y-2">
{#each ciRuns as run}
<div class="flex items-start gap-3 px-3 py-2.5 rounded-lg bg-surface-50 dark:bg-surface-700/30">
<span class="w-2 h-2 rounded-full mt-1.5 shrink-0 {run.status === 'success' ? 'bg-emerald-400' : run.status === 'failure' ? 'bg-rose-400' : run.status === 'in_progress' || run.status === 'running' ? 'bg-sky-400' : 'bg-surface-300'}"></span>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-surface-700 dark:text-surface-200 truncate">{run.display_title || "—"}</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] font-mono text-surface-400">#{run.run_number}</span>
<span class="text-[10px] text-surface-400">{run.event}</span>
<span class="text-[10px] font-mono text-surface-400">{run.head_sha?.slice(0, 8) || ""}</span>
</div>
</div>
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded {ciStatusBadge(run.status)}">{run.status}</span>
</div> </div>
{/each} {/each}
{#if repos.length > 5}
<p class="text-xs text-primary-500 font-medium pt-1">+{repos.length - 5} more</p>
{/if}
</div> </div>
{/if}
</div> </div>
</div> </div>