Compare commits
2 Commits
b2c4e73e4a
...
636a2b02e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 636a2b02e6 | |||
| bfa11b5f26 |
@@ -165,22 +165,23 @@ jobs:
|
||||
git clone --depth=1 --branch "$GITHUB_REF_NAME" "http://100.78.131.124:3300/${GITHUB_REPOSITORY}.git" .
|
||||
fi
|
||||
|
||||
- name: Build dev Docker image
|
||||
- name: Build and transfer dev Docker image
|
||||
run: |
|
||||
set -e
|
||||
echo "Building on VPS (SSD)..."
|
||||
# Podman caches layers automatically; no --cache-from needed
|
||||
podman build -t nas-dashboard-dev:latest dashboard/
|
||||
|
||||
- name: Transfer image to NAS
|
||||
run: |
|
||||
set -e
|
||||
REGISTRY="100.78.131.124:5501"
|
||||
TAG="$REGISTRY/nas-dashboard-dev:$GITHUB_SHA"
|
||||
LATEST_TAG="$REGISTRY/nas-dashboard-dev:latest"
|
||||
podman build -t "$TAG" -t "$LATEST_TAG" dashboard/
|
||||
echo "Pushing to local registry..."
|
||||
podman push --tls-verify=false "$TAG"
|
||||
podman push --tls-verify=false "$LATEST_TAG"
|
||||
echo "Streaming dev image to NAS..."
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
|
||||
podman save nas-dashboard-dev:latest | gzip | ssh $SSH_OPTS nasts "gunzip | /volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1"
|
||||
podman save "$LATEST_TAG" | gzip | ssh $SSH_OPTS nasts "gunzip | /volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1"
|
||||
echo "Retagging image..."
|
||||
ssh $SSH_OPTS nasts "/volume1/@appstore/ContainerManager/usr/bin/docker tag localhost/nas-dashboard-dev:latest nas-dashboard-dev:latest 2>&1 && /volume1/@appstore/ContainerManager/usr/bin/docker image rm localhost/nas-dashboard-dev:latest 2>&1"
|
||||
echo "Dev image transferred and retagged successfully"
|
||||
ssh $SSH_OPTS nasts "/volume1/@appstore/ContainerManager/usr/bin/docker tag $REGISTRY/nas-dashboard-dev:latest nas-dashboard-dev:latest 2>&1"
|
||||
echo "Dev image built, pushed, and transferred"
|
||||
|
||||
deploy-dev:
|
||||
name: Deploy to Dev
|
||||
|
||||
+11
-12
@@ -174,22 +174,21 @@ jobs:
|
||||
git clone --depth=1 "http://100.78.131.124:3300/${GITHUB_REPOSITORY}.git" .
|
||||
fi
|
||||
|
||||
- name: Build Docker image
|
||||
- name: Build and transfer Docker image
|
||||
run: |
|
||||
set -e
|
||||
echo "Building on VPS (SSD)..."
|
||||
# Podman caches layers automatically; no --cache-from needed
|
||||
podman build -t nas-dashboard:latest dashboard/
|
||||
|
||||
- name: Transfer image to NAS
|
||||
run: |
|
||||
set -e
|
||||
REGISTRY="100.78.131.124:5501"
|
||||
TAG="$REGISTRY/nas-dashboard:$GITHUB_SHA"
|
||||
LATEST_TAG="$REGISTRY/nas-dashboard:latest"
|
||||
podman build -t "$TAG" -t "$LATEST_TAG" dashboard/
|
||||
echo "Pushing to local registry..."
|
||||
podman push --tls-verify=false "$TAG"
|
||||
podman push --tls-verify=false "$LATEST_TAG"
|
||||
echo "Streaming image to NAS..."
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
|
||||
podman save nas-dashboard:latest | gzip | ssh $SSH_OPTS nasts "gunzip | /volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1"
|
||||
echo "Retagging image..."
|
||||
ssh $SSH_OPTS nasts "/volume1/@appstore/ContainerManager/usr/bin/docker tag localhost/nas-dashboard:latest nas-dashboard:latest 2>&1 && /volume1/@appstore/ContainerManager/usr/bin/docker image rm localhost/nas-dashboard:latest 2>&1"
|
||||
echo "Image transferred and retagged successfully"
|
||||
podman save "$LATEST_TAG" | gzip | ssh $SSH_OPTS nasts "gunzip | /volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1"
|
||||
echo "Built, pushed, and transferred: $TAG"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
@@ -233,7 +232,7 @@ jobs:
|
||||
docker rm -f docker-socket-proxy nas-dashboard 2>/dev/null || true
|
||||
echo "Old containers stopped and removed"
|
||||
|
||||
# --pull never prevents overwriting the image we just built
|
||||
# --pull never prevents overwriting the image we just pulled
|
||||
docker compose -f /nas-dashboard/docker-compose.yml up -d --pull never 2>&1 || true
|
||||
|
||||
if ! docker container inspect nas-dashboard >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -9,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
import auth_service as auth
|
||||
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()
|
||||
|
||||
@@ -151,6 +152,61 @@ def openclaw_token(request: Request):
|
||||
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")
|
||||
def external_services():
|
||||
"""Return external service URLs and network config for the frontend sidebar."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import Sidebar from "./components/Sidebar.svelte";
|
||||
import CommandPalette from "./components/CommandPalette.svelte";
|
||||
import Dashboard from "./routes/Dashboard.svelte";
|
||||
import Gitea from "./routes/Gitea.svelte";
|
||||
import Files from "./routes/Files.svelte";
|
||||
@@ -29,6 +30,36 @@
|
||||
"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 dark = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
@@ -177,6 +208,7 @@
|
||||
{:else}
|
||||
<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} />
|
||||
<CommandPalette items={navItems} onNavigate={(id) => page = id} />
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
||||
<!-- 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}
|
||||
@@ -1,23 +1,31 @@
|
||||
<script>
|
||||
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 services = $state([]);
|
||||
let ciRuns = $state([]);
|
||||
let loading = $state(true);
|
||||
let serviceLoading = $state(true);
|
||||
let ciLoading = $state(true);
|
||||
let pinned = $state([]);
|
||||
let editPinned = $state(false);
|
||||
|
||||
async function load() {
|
||||
const [c, g, s] = await Promise.all([
|
||||
get("/docker/containers"),
|
||||
get("/gitea/repos"),
|
||||
get("/system/stats"),
|
||||
]);
|
||||
containers = c || [];
|
||||
repos = Array.isArray(g) ? g : g?.data || [];
|
||||
stats = s;
|
||||
loading = false;
|
||||
}
|
||||
// Nav items for pinning
|
||||
const allNavItems = [
|
||||
{ id: "litellm", label: "LiteLLM", icon: "bolt" },
|
||||
{ id: "opc", label: "OPC", icon: "kanban" },
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||
{ id: "transmission", label: "Transmission", icon: "download" },
|
||||
{ label: "Navidrome", icon: "music", remoteHref: "https://music.jimmygan.com" },
|
||||
{ label: "Jellyfin", icon: "play", remoteHref: "https://media.jimmygan.com" },
|
||||
{ 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) {
|
||||
if (!b) return "0 B";
|
||||
@@ -32,16 +40,98 @@
|
||||
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;
|
||||
|
||||
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));
|
||||
|
||||
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>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</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}
|
||||
@@ -51,9 +141,47 @@
|
||||
{/each}
|
||||
</div>
|
||||
{: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 -->
|
||||
<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="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
</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="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
</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="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
</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="flex items-center justify-between mb-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p>
|
||||
@@ -102,50 +227,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Docker Summary -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<!-- Widget Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Service Health -->
|
||||
<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">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Docker Containers</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>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each containers.slice(0, 5) as c}
|
||||
<div class="flex items-center justify-between py-1.5 text-sm">
|
||||
<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}
|
||||
{#if containers.length > 5}
|
||||
<p class="text-xs text-primary-500 font-medium pt-1">+{containers.length - 5} more</p>
|
||||
{/if}
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Service Health</h3>
|
||||
<span class="text-xs text-surface-400">{services.filter(s => s.status === "up").length}/{services.length} online</span>
|
||||
</div>
|
||||
{#if serviceLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-10 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<!-- 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="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Git Repositories</h3>
|
||||
<span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span>
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">CI Runs</h3>
|
||||
<span class="text-xs text-surface-400">nas-tools</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each repos.slice(0, 5) as r}
|
||||
<div class="flex items-center justify-between py-1.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
{#if ciLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-14 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{: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>
|
||||
<span class="text-xs text-surface-400">{r.language || "—"}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if repos.length > 5}
|
||||
<p class="text-xs text-primary-500 font-medium pt-1">+{repos.length - 5} more</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user