Full dashboard: Proxmox hosts, LXC tables, dark mode, Hermes URLs, status dots

This commit is contained in:
2026-05-23 15:15:41 -04:00
commit 8d65e4291b
3 changed files with 631 additions and 0 deletions
Binary file not shown.
+436
View File
@@ -0,0 +1,436 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ourpad — Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--black: #171717; --white: #ffffff; --gray-600: #4d4d4d; --gray-400: #808080;
--gray-200: #d4d4d4; --gray-100: #ebebeb; --gray-50: #fafafa;
--link-blue: #0072f5; --green: #22c55e; --red: #ef4444; --amber: #f59e0b;
--ring: rgba(0,0,0,0.08) 0px 0px 0px 1px;
--card-shadow: rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, rgba(0,0,0,0.04) 0px 8px 8px -8px;
--bg: #ffffff; --bg-alt: #fafafa; --text: #171717;
}
body.dark {
--black: #e5e5e5; --white: #1a1a1a; --gray-600: #a0a0a0; --gray-400: #707070;
--gray-200: #404040; --gray-100: #2a2a2a; --gray-50: #222222;
--link-blue: #5ba4ff;
--ring: rgba(255,255,255,0.06) 0px 0px 0px 1px;
--card-shadow: rgba(255,255,255,0.06) 0px 0px 0px 1px, rgba(0,0,0,0.2) 0px 2px 4px;
--bg: #1a1a1a; --bg-alt: #222222; --text: #e5e5e5;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Geist', system-ui, -apple-system, sans-serif; font-feature-settings: 'liga'; background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; }
nav { position: sticky; top: 0; background: var(--bg); box-shadow: var(--ring); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; z-index: 100; }
.nav-brand { font-size: 18px; font-weight: 600; letter-spacing: -0.36px; color: var(--text); text-decoration: none; }
.nav-links { display: flex; gap: 24px; align-items: center; }
.nav-links a { font-size: 14px; font-weight: 500; color: var(--gray-600); text-decoration: none; transition: color 0.15s; }
.nav-links a:hover { color: var(--text); }
.hero { text-align: center; padding: 64px 24px 40px; }
.hero h1 { font-size: 42px; font-weight: 600; letter-spacing: -2px; margin-bottom: 8px; color: var(--text); }
.hero p { font-size: 17px; color: var(--gray-600); max-width: 600px; margin: 0 auto; }
.section { max-width: 1300px; margin: 0 auto; padding: 0 24px 48px; }
.section-header { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.section-header h2 { font-size: 22px; font-weight: 600; letter-spacing: -0.88px; color: var(--text); }
.badge { display: inline-flex; align-items: center; background: #ebf5ff; color: #0068d6; font-size: 12px; font-weight: 500; padding: 0 10px; height: 24px; border-radius: 9999px; }
/* Host Cards */
.host-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 16px; }
.host-card { background: var(--white); box-shadow: var(--card-shadow); border-radius: 10px; padding: 20px 24px; }
.host-card h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
.host-card .host-sub { font-size: 12px; color: var(--gray-400); margin-bottom: 14px; font-family: 'Geist Mono', monospace; }
.metric-row { display: grid; grid-template-columns: 80px 1fr 70px; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
.metric-label { font-weight: 500; color: var(--gray-600); font-size: 12px; }
.metric-bar-bg { height: 8px; background: var(--gray-100); border-radius: 4px; overflow: hidden; }
.metric-bar-fill { height: 100%; border-radius: 4px; transition: width 1s ease; }
.metric-val { font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--gray-600); text-align: right; }
/* LXC table inside host card */
.lxc-table { margin-top: 12px; border-top: 1px solid var(--gray-100); padding-top: 8px; }
.lxc-header { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--gray-400); display: grid; grid-template-columns: 1.4fr 0.6fr 0.8fr 1fr; gap: 6px; padding: 2px 0; }
.lxc-row { display: grid; grid-template-columns: 1.4fr 0.6fr 0.8fr 1fr; gap: 6px; align-items: center; font-size: 12px; padding: 3px 0; }
.lxc-row + .lxc-row { border-top: 1px solid var(--gray-50); }
.lxc-name { font-weight: 500; }
.lxc-cpu { color: var(--gray-400); font-family: 'Geist Mono', monospace; }
.lxc-bar-wrap { display: flex; align-items: center; gap: 4px; }
.lxc-bar-bg { flex: 1; height: 5px; background: var(--gray-100); border-radius: 2px; overflow: hidden; }
.lxc-bar-fill { height: 100%; border-radius: 2px; }
.lxc-pct { font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--gray-400); min-width: 32px; text-align: right; }
.lxc-off { opacity: 0.4; }
/* Service cards */
.grid-4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.svc-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: var(--white); box-shadow: var(--ring); border-radius: 8px; text-decoration: none; color: inherit; cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; }
.svc-card:hover { box-shadow: rgba(0,0,0,0.15) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 4px; transform: translateY(-1px); }
.svc-icon { font-size: 20px; flex-shrink: 0; line-height: 1.2; }
.svc-info { flex: 1; min-width: 0; }
.svc-name { font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 6px; }
.svc-desc { font-size: 12px; color: var(--gray-400); margin-top: 2px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.status-dot.online { background: var(--green); box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.status-dot.offline { background: var(--red); }
.status-dot.checking,.status-dot.timeout,.status-dot.error { background: var(--amber); animation: pulse 1s infinite; }
.status-label { font-size: 11px; font-weight: 500; }
.status-label.online { color: var(--green); }
.status-label.offline { color: var(--red); }
.status-label.checking,.status-label.timeout,.status-label.error { color: var(--amber); }
.svc-expand { display: none; padding: 10px 18px 14px; margin-top: -12px; background: var(--bg-alt); border-radius: 0 0 8px 8px; box-shadow: var(--ring); font-size: 13px; color: var(--gray-600); }
.svc-expand.open { display: block; }
.detail-row { display: flex; gap: 16px; align-items: baseline; margin-bottom: 4px; }
.detail-label { font-weight: 500; color: var(--gray-400); min-width: 36px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; }
.detail-val { font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--black); word-break: break-all; }
.detail-val a { color: var(--link-blue); text-decoration: none; }
.detail-val a:hover { text-decoration: underline; }
.expand-indicator { font-size: 12px; color: var(--gray-200); margin-left: auto; transition: transform 0.2s; flex-shrink: 0; }
.svc-card.expanded .expand-indicator { transform: rotate(180deg); color: var(--gray-400); }
.alt-bg { background: var(--bg-alt); padding: 48px 24px; box-shadow: rgba(0,0,0,0.04) 0px -1px 0px 0px, rgba(0,0,0,0.04) 0px 1px 0px 0px; }
.alt-bg + .alt-bg { padding-top: 36px; }
.alt-bg .section { padding-bottom: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.card { background: var(--white); box-shadow: var(--card-shadow); border-radius: 8px; padding: 24px; text-decoration: none; color: inherit; transition: box-shadow 0.2s, transform 0.15s; display: flex; flex-direction: column; gap: 12px; cursor: pointer; }
.card:hover { box-shadow: rgba(0,0,0,0.12) 0px 0px 0px 1px, rgba(0,0,0,0.06) 0px 2px 4px, rgba(0,0,0,0.04) 0px 12px 12px -8px; transform: translateY(-1px); }
.card-header { display: flex; align-items: center; gap: 10px; }
.card-icon { width: 36px; height: 36px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.card-title { font-size: 18px; font-weight: 600; letter-spacing: -0.36px; }
.card-desc { font-size: 13px; color: var(--gray-600); line-height: 1.6; }
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.tag { font-family: 'Geist Mono', monospace; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; color: var(--gray-400); padding: 2px 8px; background: var(--gray-50); border-radius: 4px; }
.tag.active { background: #ecfdf5; color: #065f46; }
.tag.wip { background: #fef3c7; color: #92400e; }
.tag.internal { background: #f3e8ff; color: #7c3aed; }
.card-expand { display: none; }
.card-expand.open { display: block; margin-top: 4px; }
.stats-error { font-size: 13px; color: var(--gray-400); font-style: italic; }
footer { text-align: center; padding: 32px 24px; font-size: 13px; color: var(--gray-400); }
footer a { color: var(--link-blue); text-decoration: none; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } }
@media (max-width: 768px) {
.hero { padding: 40px 24px 28px; }
.hero h1 { font-size: 28px; letter-spacing: -1.2px; }
.hero p { font-size: 15px; }
.grid, .grid-4, .host-grid { grid-template-columns: 1fr; }
.nav-links { display: none; }
.host-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav>
<a href="/" class="nav-brand">Ourpad</a>
<div class="nav-links">
<a href="https://gitea.ourpad.casa">Gitea</a>
<a href="https://pass.ourpad.casa">Pass Vault</a>
<a href="https://dashboard.ourpad.casa">Dashboard</a>
<a href="https://status.ourpad.casa">Status</a>
<button id="dark-toggle" onclick="toggleDark()" style="background:none;border:1px solid var(--gray-200);border-radius:6px;padding:4px 8px;cursor:pointer;font-size:16px;" title="Toggle dark mode">🌙</button>
</div>
</nav>
<section class="hero">
<h1>Ourpad Dashboard</h1>
<p>Live service health, Proxmox hosts, resource usage, and project links.</p>
</section>
<!-- ─── HOST RESOURCES ─── -->
<div class="section">
<div class="section-header"><h2>🖥️ Proxmox Hosts</h2><span class="badge" id="host-count">2</span></div>
<div class="host-grid" id="host-cards">
<div class="host-card"><span class="stats-error">Loading host stats...</span></div>
</div>
</div>
<!-- ─── PUBLIC SERVICES ─── -->
<div class="alt-bg">
<div class="section">
<div class="section-header"><h2>🌐 Public Services</h2><span class="badge" id="pub-count">22</span></div>
<p style="font-size:13px;color:var(--gray-400);margin:-8px 0 20px;">All behind NPMplus + Cloudflare — click to expand for URLs</p>
<div class="grid-4" id="public-services"></div>
</div>
</div>
<!-- ─── INTERNAL SERVICES ─── -->
<div class="alt-bg">
<div class="section">
<div class="section-header"><h2>🔒 Internal Services</h2><span class="badge" id="int-count">0</span></div>
<p style="font-size:13px;color:var(--gray-400);margin:-8px 0 20px;">Tailscale / internal network only</p>
<div class="grid-4" id="internal-services"></div>
</div>
</div>
<!-- ─── PROJECTS ─── -->
<div class="section">
<div class="section-header"><h2>🛠️ Projects</h2><span class="badge">8</span></div>
<div class="grid">
<a href="https://gitea.ourpad.casa/shawn/pass-vault" class="card">
<div class="card-header"><div class="card-icon" style="background:#fef3c7;">🔐</div><span class="card-title">Pass Vault</span></div>
<p class="card-desc">Browser-based password manager — GPG-encrypted pass store with dark-themed web UI.</p>
<div class="card-meta"><span class="tag">FastAPI</span><span class="tag">pass</span><span class="tag active">active</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/canteen-asset-tracker" class="card">
<div class="card-header"><div class="card-icon" style="background:#e0f2fe;">📍</div><span class="card-title">Canteen Asset Tracker</span></div>
<p class="card-desc">Mobile webapp — machine-id scanning, OCR, GPS check-ins, geofences, CSV export.</p>
<div class="card-meta"><span class="tag">FastAPI</span><span class="tag">SQLite</span><span class="tag wip">in progress</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/canteen-web-app" class="card">
<div class="card-header"><div class="card-icon" style="background:#fce7f3;">🌐</div><span class="card-title">Canteen Web App</span></div>
<p class="card-desc">Full-stack frontend — login, navigation, asset CRUD, reporting.</p>
<div class="card-meta"><span class="tag">Frontend</span><span class="tag wip">in progress</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/photo-geotagger" class="card">
<div class="card-header"><div class="card-icon" style="background:#f0fdf4;">📍</div><span class="card-title">Photo Geotagger</span></div>
<p class="card-desc">Batch GPS updater — EXIF extraction, OCR scanning, Leaflet map review.</p>
<div class="card-meta"><span class="tag">Python</span><span class="tag">FastAPI</span><span class="tag active">active</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/raspberry-pi-mixer" class="card">
<div class="card-header"><div class="card-icon" style="background:#fef2f2;">🎛️</div><span class="card-title">Raspberry Pi Mixer</span></div>
<p class="card-desc">Custom Linux distro for RPi4B — low-latency digital mixer, VST/LV2, touchscreen.</p>
<div class="card-meta"><span class="tag">Embedded Linux</span><span class="tag">Audio DSP</span><span class="tag active">active</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/project-directory" class="card">
<div class="card-header"><div class="card-icon" style="background:#ecfdf5;">📂</div><span class="card-title">Project Directory</span></div>
<p class="card-desc">This page — a living dashboard of all Ourpad projects and infrastructure.</p>
<div class="card-meta"><span class="tag">HTML/CSS</span><span class="tag active">active</span></div>
</a>
<a href="https://github.com/NousResearch/hermes-agent" class="card">
<div class="card-header"><div class="card-icon" style="background:#ede9fe;">🤖</div><span class="card-title">Hermes Agent</span></div>
<p class="card-desc">AI agent framework — 3 profiles, cron jobs, kanban, MCP tools, multi-model.</p>
<div class="card-meta"><span class="tag">Python</span><span class="tag">AI/ML</span><span class="tag active">active</span></div>
</a>
<a href="https://obsidian.md" class="card">
<div class="card-header"><div class="card-icon" style="background:#f1f5f9;">📝</div><span class="card-title">Obsidian Vault</span></div>
<p class="card-desc">Knowledge management — three-layer vault, LLM Wiki, daily notes, automated reviews.</p>
<div class="card-meta"><span class="tag">Obsidian</span><span class="tag">Dataview</span><span class="tag active">active</span></div>
</a>
</div>
</div>
<!-- ─── INFRASTRUCTURE ─── -->
<div class="alt-bg">
<div class="section">
<div class="section-header"><h2>🏗️ Infrastructure</h2></div>
<div class="grid">
<div class="card nolink" onclick="toggleCardExpand(this)">
<div class="card-header"><div class="card-icon" style="background:#f0fdf4;">💾</div><span class="card-title">NAS Storage</span><span class="expand-indicator"></span></div>
<p class="card-desc"><strong>3.6TB ext4</strong> at /mnt/nas — bind-mounted into all arr LXCs. Media library, torrents, backups.</p>
<div class="card-meta"><span class="tag">/dev/sdb</span><span class="tag">ext4</span></div>
<div class="card-expand"><div class="detail-row"><span class="detail-label">Mount</span><span class="detail-val">/mnt/nas</span></div><div class="detail-row"><span class="detail-label">Device</span><span class="detail-val">/dev/sdb</span></div></div>
</div>
<div class="card nolink" onclick="toggleCardExpand(this)">
<div class="card-header"><div class="card-icon" style="background:#f5f3ff;">🔒</div><span class="card-title">Networking</span><span class="expand-indicator"></span></div>
<p class="card-desc"><strong>Cloudflare</strong> DNS + proxy (68.202.6.107) → NPMplus (192.168.0.115) → internal services. <strong>Tailscale</strong> mesh.</p>
<div class="card-meta"><span class="tag">ourpad.casa</span><span class="tag">TLS 1.3</span></div>
<div class="card-expand"><div class="detail-row"><span class="detail-label">WAN</span><span class="detail-val">68.202.6.107</span></div><div class="detail-row"><span class="detail-label">Proxy</span><span class="detail-val">192.168.0.115</span></div><div class="detail-row"><span class="detail-label">VPN</span><span class="detail-val">Tailscale mesh — 2 nodes</span></div></div>
</div>
</div>
</div>
</div>
<footer>
<p>Built with <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> · <a href="https://gitea.ourpad.casa/shawn/project-directory">Source</a></p>
</footer>
<script>
// ─── Service Definitions ──────────────────────────────────────────────
const SERVICES = [
{ id:"gitea", name:"Gitea", desc:"Self-hosted Git — repos, issues, CI", icon:"🦊", url:"https://gitea.ourpad.casa", ip:"192.168.0.223", section:"public" },
{ id:"pass-vault", name:"Pass Vault", desc:"Web-based secret management", icon:"🔐", url:"https://pass.ourpad.casa", ip:"192.168.0.223", section:"public" },
{ id:"projects", name:"Project Directory", desc:"This page", icon:"📂", url:"https://projects.ourpad.casa", ip:"127.0.0.1:18834", section:"public" },
{ id:"hermes", name:"Hermes Dashboard", desc:"Agent management UI", icon:"🤖", url:"https://hermes.ourpad.casa", ip:"127.0.0.1:9119", section:"public" },
{ id:"coder", name:"Hermes Coder", desc:"Agent dashboard (coder profile)", icon:"💻", url:"https://coder.ourpad.casa", ip:"127.0.0.1:9120", section:"public" },
{ id:"system-db", name:"System Dashboard", desc:"Real-time server monitoring", icon:"📊", url:"https://system-dashboard.ourpad.casa", ip:"127.0.0.1:8899", section:"public" },
{ id:"canteen", name:"Canteen App", desc:"Asset tracker (WIP)", icon:"🏗️", url:"https://canteen.ourpad.casa", ip:"127.0.0.1:8901", section:"public" },
{ id:"canteen-admin",name:"Canteen Admin", desc:"Asset tracker admin panel", icon:"🛠️", url:"https://admin.canteen.ourpad.casa", ip:"127.0.0.1:8090", section:"public" },
{ id:"geotagger", name:"Photo Geotagger", desc:"Batch GPS updater", icon:"📍", url:"https://geotagger.ourpad.casa", ip:"127.0.0.1:8902", section:"public" },
{ id:"sonarr", name:"Sonarr", desc:"TV show automation", icon:"📺", url:"https://sonarr.ourpad.casa", ip:"192.168.0.178", section:"public" },
{ id:"radarr", name:"Radarr", desc:"Movie automation", icon:"🎬", url:"https://radarr.ourpad.casa", ip:"192.168.0.123", section:"public" },
{ id:"jellyseerr", name:"Jellyseerr", desc:"Media requests", icon:"🎞️", url:"https://jellyseerr.ourpad.casa", ip:"192.168.0.229", section:"public" },
{ id:"deluge", name:"Deluge", desc:"Torrent client", icon:"📥", url:"https://deluge.ourpad.casa", ip:"192.168.0.243", section:"public" },
{ id:"jellyfin", name:"Jellyfin", desc:"Media streaming", icon:"▶️", url:"https://jellyfin.ourpad.casa", ip:"192.168.0.125", section:"public" },
{ id:"obsidian", name:"Obsidian LiveSync", desc:"CouchDB sync server", icon:"🔄", url:"https://obsidian.ourpad.casa", ip:"192.168.0.223", section:"public" },
{ id:"inventree", name:"Inventree", desc:"Inventory management", icon:"📦", url:"https://inventory.ourpad.casa", ip:"192.168.0.132", section:"public" },
{ id:"npmplus", name:"NPMplus", desc:"Reverse proxy admin", icon:"🌐", url:"https://npmplus.ourpad.casa", ip:"192.168.0.115", section:"public" },
{ id:"proxmox", name:"Proxmox Admin", desc:"VE management UI", icon:"🖥️", url:"https://admin.ourpad.casa", ip:"192.168.0.144:8006", section:"public" },
{ id:"rustdesk", name:"RustDesk", desc:"Remote desktop relay", icon:"🖧", url:"https://rustdesk.ourpad.casa", ip:"192.168.0.144", section:"public" },
{ id:"status", name:"Network Status", desc:"Live service health monitor", icon:"📡", url:"https://status.ourpad.casa", ip:"127.0.0.1:18898", section:"public" },
{ id:"dashboard", name:"Heimdall Dashboard", desc:"Service landing page", icon:"🏠", url:"https://dashboard.ourpad.casa", ip:"192.168.0.213", section:"public", nostatus:true },
// Internal
{ id:"prowlarr", name:"Prowlarr", desc:"Indexer aggregator (CT 104)", icon:"🔍", url:null, ip:"192.168.0.225", section:"internal" },
{ id:"readarr", name:"Readarr", desc:"Book management (CT 108)", icon:"📚", url:null, ip:"192.168.0.193", section:"internal" },
{ id:"configarr", name:"Configarr", desc:"Config sync (CT 107)", icon:"⚙️", url:null, ip:"192.168.0.238", section:"internal" },
{ id:"unpackerr", name:"Unpackerr", desc:"RAR extraction (CT 114)", icon:"📦", url:null, ip:"192.168.0.119", section:"internal" },
{ id:"element", name:"Element Synapse", desc:"Matrix server (CT 112)", icon:"💬", url:null, ip:"192.168.0.220", section:"internal" },
{ id:"portainer", name:"Portainer", desc:"Docker management (CT 100:9443)", icon:"🐳", url:null, ip:"192.168.0.223:9443", section:"internal" },
{ id:"hermes-webui", name:"Hermes WebUI", desc:"Agent dashboard (CT 102:8787)", icon:"🧠", url:null, ip:"100.89.190.113:8787", section:"internal" },
{ id:"deskbrid", name:"Deskbrid", desc:"Desktop automation", icon:"⌨️", url:null, ip:"127.0.0.1", section:"internal" },
{ id:"cf-ddns", name:"Cloudflare DDNS", desc:"Dynamic DNS updater (CT 105)", icon:"☁️", url:null, ip:"192.168.0.110", section:"internal" },
{ id:"tailscale", name:"Tailscale", desc:"Mesh VPN — 2 nodes", icon:"🔗", url:null, ip:"100.89.190.113", section:"internal", nostatus:true },
];
// ─── Render Service Cards ─────────────────────────────────────────────
function renderServices() {
const pubEl = document.getElementById("public-services");
const intEl = document.getElementById("internal-services");
pubEl.innerHTML = ""; intEl.innerHTML = "";
let pubCount = 0, intCount = 0;
for (const s of SERVICES) {
const isInternal = s.section === "internal";
const el = isInternal ? intEl : pubEl;
const tag = s.url ? "a" : "span";
const href = s.url ? `href="${s.url}" target="_blank"` : "";
const cls = s.url ? "svc-card" : "svc-card nolink";
const onclick = s.url ? `onclick="toggleSvcExpand(this)"` : `onclick="toggleSvcExpand(this);event.preventDefault()"`;
el.insertAdjacentHTML("beforeend", `<${tag} ${href} class="${cls}" data-svc="${s.id}" data-url="${s.url||''}" data-ip="${s.ip||''}" ${s.nostatus ? 'data-nostatus="1"' : ''} ${onclick}>
<span class="svc-icon">${s.icon}</span>
<div class="svc-info">
<div class="svc-name">
${s.nostatus ? '<span class="status-dot" style="background:#d4d4d4"></span><span class="status-label" style="color:#808080">N/A</span>' : '<span class="status-dot checking"></span><span class="status-label checking">Checking...</span>'}
${s.name}
<span class="expand-indicator">▾</span>
</div>
<div class="svc-desc">${s.desc}</div>
</div>
</${tag}>
<div class="svc-expand" data-for="${s.id}">
${s.url ? `<div class="detail-row"><span class="detail-label">URL</span><span class="detail-val"><a href="${s.url}" target="_blank">${s.url}</a></span></div>` : ""}
${s.ip ? `<div class="detail-row"><span class="detail-label">IP</span><span class="detail-val">${s.ip}</span></div>` : ""}
</div>`);
if (isInternal) intCount++; else pubCount++;
}
document.getElementById("pub-count").textContent = pubCount;
document.getElementById("int-count").textContent = intCount;
}
function toggleSvcExpand(card) {
if (card.tagName === "A" && card.href && !card.classList.contains("expanded")) {
card.classList.add("expanded");
const panel = document.querySelector(`.svc-expand[data-for="${card.dataset.svc}"]`);
if (panel) panel.classList.add("open");
return;
}
if (card.tagName === "A" && card.classList.contains("expanded")) return; // let link navigate
card.classList.toggle("expanded");
const panel = document.querySelector(`.svc-expand[data-for="${card.dataset.svc}"]`);
if (panel) panel.classList.toggle("open");
}
function toggleCardExpand(card) {
card.classList.toggle("expanded");
const exp = card.querySelector(".card-expand");
if (exp) exp.classList.toggle("open");
}
// ─── Status Updates ───────────────────────────────────────────────────
async function updateStatus() {
try {
const r = await fetch("/api/status");
const d = await r.json();
const byId = {};
for (const s of d.services) { byId[s.id] = s.status; }
document.querySelectorAll("[data-svc]").forEach(el => {
if (el.dataset.nostatus) return; // skip non-HTTP services
const sid = el.dataset.svc;
const dot = el.querySelector(".status-dot");
const lbl = el.querySelector(".status-label");
const st = byId[sid] || "checking";
const labelMap = { online:"Online", offline:"Offline", timeout:"Timeout", error:"Error", checking:"Checking..." };
if (dot) dot.className = "status-dot " + st;
if (lbl) { lbl.className = "status-label " + st; lbl.textContent = labelMap[st] || st; }
});
} catch(e) { console.log("status check failed", e); }
}
// ─── Proxmox Host Stats ───────────────────────────────────────────────
function barColor(pct) {
if (pct > 90) return "#ef4444";
if (pct > 70) return "#f59e0b";
return "#22c55e";
}
function renderHosts(hosts) {
const container = document.getElementById("host-cards");
if (!hosts || hosts.length === 0) {
container.innerHTML = '<div class="host-card"><span class="stats-error">No host data</span></div>';
return;
}
let html = "";
for (const host of hosts) {
if (host.error) {
html += `<div class="host-card"><h3>🖥️ ${host.name}</h3><span class="stats-error">Error: ${host.error}</span></div>`;
continue;
}
const n = host.node;
html += `<div class="host-card">
<h3>🖥️ ${host.name}</h3>
<div class="host-sub">${n.pveversion} · uptime ${n.uptime_hrs}h · node ${n.node}</div>
<div class="metric-row"><span class="metric-label">CPU</span><div class="metric-bar-bg"><div class="metric-bar-fill" style="width:${Math.min(n.cpu,100)}%;background:${barColor(n.cpu)}"></div></div><span class="metric-val">${n.cpu}%</span></div>
<div class="metric-row"><span class="metric-label">RAM</span><div class="metric-bar-bg"><div class="metric-bar-fill" style="width:${n.memory.pct}%;background:${barColor(n.memory.pct)}"></div></div><span class="metric-val">${n.memory.used_mb}/${n.memory.total_mb} MB</span></div>
<div class="metric-row"><span class="metric-label">Disk</span><div class="metric-bar-bg"><div class="metric-bar-fill" style="width:${n.disk.pct}%;background:${barColor(n.disk.pct)}"></div></div><span class="metric-val">${n.disk.used_gb}/${n.disk.total_gb} GB</span></div>
${n.swap.used_mb > 0 ? `<div class="metric-row"><span class="metric-label">Swap</span><div class="metric-bar-bg"><div class="metric-bar-fill" style="width:${Math.round(n.swap.used_mb/Math.max(n.swap.total_mb,1)*100)}%;background:#d4d4d4"></div></div><span class="metric-val">${n.swap.used_mb}/${n.swap.total_mb} MB</span></div>` : ""}
${host.lxcs && host.lxcs.length > 0 ? `
<div class="lxc-table">
<div class="lxc-header"><span>Container</span><span>CPU</span><span>RAM</span><span>Disk</span></div>
${host.lxcs.map(ct => `
<div class="lxc-row ${ct.status !== 'running' ? 'lxc-off' : ''}">
<span class="lxc-name">CT${ct.vmid} ${ct.name}</span>
<span class="lxc-cpu">${ct.cpu_pct}%</span>
<div class="lxc-bar-wrap"><div class="lxc-bar-bg"><div class="lxc-bar-fill" style="width:${ct.mem_pct}%;background:${barColor(ct.mem_pct)}"></div></div><span class="lxc-pct">${ct.mem_mb}M</span></div>
<div class="lxc-bar-wrap"><div class="lxc-bar-bg"><div class="lxc-bar-fill" style="width:${ct.disk_pct}%;background:${barColor(ct.disk_pct)}"></div></div><span class="lxc-pct">${ct.disk_gb}G</span></div>
</div>
`).join('')}
</div>` : ""}
</div>`;
}
container.innerHTML = html;
document.getElementById("host-count").textContent = hosts.length;
}
async function updateHosts() {
try {
const r = await fetch("/api/proxmox/stats");
const d = await r.json();
renderHosts(d.hosts);
} catch(e) { console.log("proxmox fetch failed", e); }
}
// ─── Init ─────────────────────────────────────────────────────────────
renderServices();
updateStatus();
updateHosts();
setInterval(updateStatus, 60000);
setInterval(updateHosts, 30000);
// ─── Dark Mode Toggle ──────────────────────────────────────────────────
function toggleDark() {
document.body.classList.toggle("dark");
const isDark = document.body.classList.contains("dark");
document.getElementById("dark-toggle").textContent = isDark ? "☀️" : "🌙";
localStorage.setItem("darkMode", isDark ? "1" : "0");
}
// Restore preference
if (localStorage.getItem("darkMode") === "1") {
document.body.classList.add("dark");
document.getElementById("dark-toggle").textContent = "☀️";
}
</script>
</body>
</html>
Executable
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Serve project-directory static HTML with a /api/status proxy."""
import os
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
import httpx
SCRIPT_DIR = Path(__file__).resolve().parent
STATIC_DIR = SCRIPT_DIR
PORT = int(os.environ.get("SERVE_PORT", "18834"))
app = FastAPI(title="Project Directory")
@app.get("/api/status")
async def status_proxy():
"""Proxy status from the internal network-status service."""
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
) as client:
r = await client.get("http://127.0.0.1:18898/api/status")
return Response(content=r.text, media_type="application/json", status_code=r.status_code)
except Exception:
return {"error": "status service unreachable"}
import base64 as _base64
import time as _time
import asyncio as _asyncio
_SYSDB_AUTH = "Basic " + _base64.b64encode(b"shawn:Brett85!@").decode()
# Proxmox host configs
_PVE_HOSTS = [
{"id": "primary", "name": "Primary Proxmox", "url": "https://192.168.0.144:8006", "node": "pve"},
{"id": "secondary", "name": "Secondary Proxmox", "url": "https://admin.ourpad.casa", "node": "ourpad"},
]
_PVE_USER = "root@pam"
_PVE_PASS = "Brett85!@"
_pve_cache = {"data": None, "ts": 0}
_PVE_CACHE_TTL = 30.0
async def _pve_ticket(client: httpx.AsyncClient, base_url: str) -> str:
"""Get a Proxmox API ticket."""
r = await client.post(f"{base_url}/api2/json/access/ticket",
data={"username": _PVE_USER, "password": _PVE_PASS})
return r.json()["data"]["ticket"]
async def _pve_node_status(client: httpx.AsyncClient, base_url: str, node: str, ticket: str) -> dict:
r = await client.get(f"{base_url}/api2/json/nodes/{node}/status",
headers={"Cookie": f"PVEAuthCookie={ticket}"})
d = r.json()["data"]
mem = d.get("memory", {})
rootfs = d.get("rootfs", {})
swap = d.get("swap", {})
return {
"node": node,
"status": d.get("status", "unknown"),
"cpu": round(d.get("cpu", 0) * 100, 1),
"cpu_model": (d.get("cpuinfo", {}).get("model", "")[:60] if d.get("cpuinfo") else ""),
"memory": {
"total_mb": round(mem.get("total", 0) / 1024 / 1024, 0),
"used_mb": round(mem.get("used", 0) / 1024 / 1024, 0),
"free_mb": round(mem.get("free", 0) / 1024 / 1024, 0),
"pct": round(mem.get("used", 0) / max(mem.get("total", 1), 1) * 100, 1) if mem else 0,
},
"disk": {
"total_gb": round(rootfs.get("total", 0) / 1024 / 1024 / 1024, 1),
"used_gb": round(rootfs.get("used", 0) / 1024 / 1024 / 1024, 1),
"free_gb": round(rootfs.get("free", 0) / 1024 / 1024 / 1024, 1),
"pct": round(rootfs.get("used", 0) / max(rootfs.get("total", 1), 1) * 100, 1) if rootfs else 0,
},
"swap": {
"total_mb": round(swap.get("total", 0) / 1024 / 1024, 0),
"used_mb": round(swap.get("used", 0) / 1024 / 1024, 0),
},
"uptime_hrs": round(d.get("uptime", 0) / 3600, 1),
"pveversion": d.get("pveversion", ""),
}
async def _pve_lxc_list(client: httpx.AsyncClient, base_url: str, node: str, ticket: str) -> list:
r = await client.get(f"{base_url}/api2/json/nodes/{node}/lxc",
headers={"Cookie": f"PVEAuthCookie={ticket}"})
results = []
for ct in r.json()["data"]:
disk_bytes = ct.get("disk", 0)
maxdisk_bytes = ct.get("maxdisk", 0)
mem_bytes = ct.get("mem", 0)
maxmem_bytes = ct.get("maxmem", 0)
results.append({
"vmid": ct["vmid"],
"name": ct.get("name", "?"),
"status": ct.get("status"),
"cpu_pct": round(ct.get("cpu", 0) * 100, 1),
"mem_mb": round(mem_bytes / 1024 / 1024, 0),
"mem_max_mb": round(maxmem_bytes / 1024 / 1024, 0),
"mem_pct": round(mem_bytes / max(maxmem_bytes, 1) * 100, 1),
"disk_gb": round(disk_bytes / 1024 / 1024 / 1024, 1),
"disk_max_gb": round(maxdisk_bytes / 1024 / 1024 / 1024, 1),
"disk_pct": round(disk_bytes / max(maxdisk_bytes, 1) * 100, 1),
})
return results
@app.get("/api/proxmox/stats")
async def proxmox_stats():
"""Aggregate stats from both Proxmox hosts (cached 30s)."""
global _pve_cache
now = _time.time()
if _pve_cache["data"] is not None and (now - _pve_cache["ts"]) < _PVE_CACHE_TTL:
return _pve_cache["data"]
hosts_data = []
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(15.0)) as client:
for host in _PVE_HOSTS:
try:
ticket = await _pve_ticket(client, host["url"])
node_data, lxcs = await _asyncio.gather(
_pve_node_status(client, host["url"], host["node"], ticket),
_pve_lxc_list(client, host["url"], host["node"], ticket),
)
hosts_data.append({
"id": host["id"],
"name": host["name"],
"node": node_data,
"lxcs": lxcs,
})
except Exception as e:
hosts_data.append({
"id": host["id"],
"name": host["name"],
"error": str(e)[:120],
})
data = {"hosts": hosts_data, "timestamp": now}
_pve_cache["data"] = data
_pve_cache["ts"] = now
return data
@app.get("/api/system-stats")
async def system_stats_proxy():
"""Proxy system stats from the internal system-dashboard service."""
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
) as client:
r = await client.get("http://127.0.0.1:8899/api/stats",
headers={"Authorization": _SYSDB_AUTH})
return Response(content=r.text, media_type="application/json", status_code=r.status_code)
except Exception:
return {"error": "system dashboard unreachable"}
@app.get("/api/system-history")
async def system_history_proxy():
"""Proxy history from the internal system-dashboard service."""
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
) as client:
r = await client.get("http://127.0.0.1:8899/api/history",
headers={"Authorization": _SYSDB_AUTH})
return Response(content=r.text, media_type="application/json", status_code=r.status_code)
except Exception:
return {"error": "system dashboard unreachable"}
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/{path:path}")
async def index(path: str = ""):
"""Serve static files, fallback to index.html."""
file_path = STATIC_DIR / (path or "index.html")
if file_path.is_file():
return HTMLResponse(file_path.read_text())
file_path = STATIC_DIR / "index.html"
if file_path.is_file():
return HTMLResponse(file_path.read_text())
return HTMLResponse("<h1>404</h1>", 404)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=PORT)