Full dashboard: Proxmox hosts, LXC tables, dark mode, Hermes URLs, status dots
This commit is contained in:
Binary file not shown.
+436
@@ -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>
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user