Files
project-directory/index.html
T

439 lines
31 KiB
HTML

<!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 rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/favicon.svg">
<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>