Compare commits

..

13 Commits

Author SHA1 Message Date
shawn dc932ea9dd Fix dark mode: sync dark class to html element for Tailwind 2026-05-26 21:56:11 -04:00
shawn 315a0dfbcb Fix: remove x-collision typo, use x-transition instead 2026-05-26 21:51:06 -04:00
shawn 7ecce4c176 Network topology diagram + 3-address expand on each service card (EXT/LAN/TS) 2026-05-26 21:49:59 -04:00
shawn 13b61baf65 Add AGENTS.md for AI agent context 2026-05-25 20:19:01 -04:00
shawn 8b96c3ffc9 Add live Windows Gaming PC stats with metric bars
- New API /api/windows/stats: SSHes into gamingpc, runs PowerShell for CPU/RAM/Disk/Uptime
- Live metric bars on the Windows PC card (CPU/RAM/C:) updating every 30s
- Duplicate barColor() removed
2026-05-25 01:10:15 -04:00
shawn 1e60745e47 Add Windows Gaming PC to Infrastructure section
- Full system specs: i7-8700K, RTX 2080 4GB, 32GB DDR4, 3 drives
- ComfyUI + Ollama service references
- Tailscale and LAN IPs
- Expandable card with CPU/GPU/RAM/Storage/Login details
2026-05-25 01:03:59 -04:00
shawn 1957bcf8e4 Add canteen dev/seed services and seed import project card
- Add 4 new service entries: Canteen App (Dev), Admin (Dev), Seed Import (Prod/Dev)
- Add Canteen Seed Import project card (9th project)
- Update badge count 8→9
2026-05-25 01:00:04 -04:00
shawn 96e29c3629 fix: add nostatus:true to internal services so they show N/A instead of stuck Checking... 2026-05-23 15:50:09 -04:00
shawn 5bb34e728c add Docker container stats endpoint + dashboard section 2026-05-23 15:30:06 -04:00
shawn 2f588e78ac add favicon (SVG stacked-file design) 2026-05-23 15:22:03 -04:00
shawn 488239c593 support SERVE_HOST env var for binding to 0.0.0.0 2026-05-23 15:18:45 -04:00
shawn 934ad40c56 merge: use local dashboard (Proxmox hosts, LXC tables, dark mode, Hermes URLs) 2026-05-23 15:15:46 -04:00
shawn 8d65e4291b Full dashboard: Proxmox hosts, LXC tables, dark mode, Hermes URLs, status dots 2026-05-23 15:15:41 -04:00
5 changed files with 1032 additions and 280 deletions
+56
View File
@@ -0,0 +1,56 @@
# Project Directory — Agent Guide
Living index page of the Ourpad home lab. Lists self-hosted projects, public services, internal services, infrastructure details, and LXC inventory — single-page reference for the entire homelab.
## Stack
- **Python** 3.11+, **FastAPI**, **uvicorn**
- **httpx** — proxied API calls to Proxmox and network-status
- **Frontend:** static HTML/CSS with embedded JS, SVG favicon
## Project Structure
```
serve.py # ~300 lines — FastAPI server, static file + API proxy
index.html # ~41KB — self-contained HTML with all project/service data
favicon.svg # SVG favicon
README.md
```
## How to Run
```bash
python serve.py
```
Binds to `127.0.0.1` by default (port configurable via `SERVE_PORT` env var).
## Key Architecture
- Serves `index.html` as static content
- `/api/status` — proxies to internal network-status service for live status checks
- Proxmox API integration — fetches LXC/VM status from two Proxmox hosts
- All project/service data embedded directly in `index.html` (no separate data files)
## Production
- **URL:** https://projects.ourpad.casa
- **Port:** 18834 (default, configurable)
- **Binds to:** 127.0.0.1 (NPMPlus reverse proxy)
- **No systemd**
## Environment Variables
| Var | Default | Description |
|-----|---------|-------------|
| SERVE_PORT | 18834 | Server listen port |
## Security Note
`serve.py` contains Proxmox admin credentials (base64 encoded). This is internal- only behind NPMPlus proxy. Do not expose directly to the internet.
## Pitfalls
- **Single page** — `index.html` has all data hardcoded; adding a new service requires editing the HTML directly
- Proxmox credentials embedded in source — use environment-based auth if exposing more broadly
- No CI/CD — update by editing `index.html` and `serve.py` directly on the server
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#171717"/>
<rect x="20" y="15" width="60" height="20" rx="4" fill="#ebf5ff"/>
<rect x="20" y="40" width="60" height="20" rx="4" fill="#ecfdf5"/>
<rect x="20" y="65" width="60" height="20" rx="4" fill="#fef3c7"/>
<circle cx="35" cy="25" r="5" fill="#0072f5"/>
<circle cx="35" cy="50" r="5" fill="#22c55e"/>
<circle cx="35" cy="75" r="5" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

+656 -270
View File
@@ -3,320 +3,706 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ourpad — Projects</title> <title>Ourpad — Infrastructure</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"> <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;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Geist', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
}
}
}
}
</script>
<style> <style>
:root {
--black: #171717;
--white: #ffffff;
--gray-600: #4d4d4d;
--gray-400: #808080;
--gray-100: #ebebeb;
--gray-50: #fafafa;
--link-blue: #0072f5;
--focus-blue: hsla(212, 100%, 48%, 1);
--badge-bg: #ebf5ff;
--badge-text: #0068d6;
--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, #fafafa 0px 0px 0px 1px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-feature-settings: 'liga';
background: var(--white);
color: var(--black);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
nav {
position: sticky; top: 0; background: var(--white);
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(--black); 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(--black); }
.hero { text-align: center; padding: 100px 24px 64px; }
.hero h1 { font-size: 48px; font-weight: 600; line-height: 1.0; letter-spacing: -2.4px; margin-bottom: 16px; }
.hero p { font-size: 20px; font-weight: 400; line-height: 1.8; color: var(--gray-600); max-width: 560px; margin: 0 auto; }
.section { max-width: 1200px; margin: 0 auto; padding: 0 24px 64px; }
.section-header { display: flex; align-items: center; gap: 8px; margin-bottom: 32px; }
.section-header h2 { font-size: 28px; font-weight: 600; letter-spacing: -1.12px; }
.section-header .badge { display: inline-flex; align-items: center; background: var(--badge-bg); color: var(--badge-text); font-size: 12px; font-weight: 500; padding: 0 10px; height: 24px; border-radius: 9999px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.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;
}
.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, #fafafa 0px 0px 0px 1px; transform: translateY(-1px); }
.card.nolink { pointer-events: none; } /* but still show hover */
.card.nolink:hover { transform: none; box-shadow: var(--card-shadow); }
.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; font-weight: 400; 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', ui-monospace, SFMono-Regular, Menlo, Monaco, 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; }
/* Service mini-card */
.svc-card {
display: flex; align-items: center; gap: 12px; padding: 14px 18px;
background: var(--white); box-shadow: var(--ring); border-radius: 8px;
text-decoration: none; transition: box-shadow 0.15s;
}
.svc-card:hover { box-shadow: rgba(0,0,0,0.15) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 4px; }
.svc-icon { font-size: 20px; flex-shrink: 0; }
.svc-name { font-size: 14px; font-weight: 500; color: var(--black); }
.svc-desc { font-size: 12px; color: var(--gray-400); margin-top: 1px; }
.svc-internal { opacity: 0.7; }
.svc-internal:hover { opacity: 1; }
.alt-bg { background: var(--gray-50); padding: 64px 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 .section { padding-bottom: 0; }
.alt-bg + .alt-bg { box-shadow: rgba(0,0,0,0.04) 0px 1px 0px 0px; padding-top: 48px; }
footer { text-align: center; padding: 32px 24px; font-size: 13px; color: var(--gray-400); }
footer a { color: var(--link-blue); text-decoration: none; }
@media (max-width: 768px) {
.hero { padding: 48px 24px 40px; }
.hero h1 { font-size: 32px; letter-spacing: -1.6px; }
.hero p { font-size: 16px; }
.grid, .grid-4 { grid-template-columns: 1fr; }
.section-header h2 { font-size: 22px; }
.nav-links { display: none; }
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-right: 4px; }
.status-dot.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.status-dot.offline { background: #ef4444; }
.status-dot.checking { background: #f59e0b; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } } @keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } }
.svc-status { display: flex; align-items: center; gap: 2px; } .animate-pulse-slow { animation: pulse 2s infinite; }
[x-cloak] { display: none !important; }
.topo-line { stroke: #d4d4d4; stroke-width: 1.5; fill: none; }
.dark .topo-line { stroke: #404040; }
.topo-box { fill: #fafafa; stroke: #d4d4d4; stroke-width: 1; rx: 6; }
.dark .topo-box { fill: #222; stroke: #404040; }
.topo-pill { fill: #ebf5ff; stroke: none; rx: 4; }
.dark .topo-pill { fill: #1e3a5f; }
</style> </style>
</head> </head>
<body> <body class="bg-white dark:bg-[#1a1a1a] text-[#171717] dark:text-[#e5e5e5] antialiased min-h-screen" x-data="dashboard()" :class="{ 'dark': dark }" x-init="init()">
<nav> <!-- ════════════════════════════════════ -->
<a href="/" class="nav-brand">Ourpad</a> <!-- NAV -->
<div class="nav-links"> <!-- ════════════════════════════════════ -->
<a href="https://gitea.ourpad.casa">Gitea</a> <nav class="sticky top-0 z-50 bg-white/90 dark:bg-[#1a1a1a]/90 backdrop-blur-md border-b border-black/5 dark:border-white/5 px-6 h-14 flex items-center justify-between">
<a href="https://pass.ourpad.casa">Pass Vault</a> <a href="/" class="text-lg font-semibold tracking-tight">🏠 <span class="hidden sm:inline">Ourpad</span></a>
<a href="https://dashboard.ourpad.casa">Dashboard</a> <div class="flex items-center gap-4 sm:gap-6">
<a href="https://status.ourpad.casa">Status</a> <a href="https://gitea.ourpad.casa" class="text-sm font-medium text-[#808080] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-white transition-colors">Gitea</a>
<a href="https://pass.ourpad.casa" class="text-sm font-medium text-[#808080] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-white transition-colors">Pass</a>
<a href="https://status.ourpad.casa" class="text-sm font-medium text-[#808080] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-white transition-colors">Status</a>
<span class="text-[#d4d4d4] dark:text-[#404040] hidden sm:inline">|</span>
<a href="https://canteen.ourpad.casa" class="text-sm font-medium text-[#808080] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-white transition-colors hidden sm:inline">Canteen</a>
<a href="https://hermes.ourpad.casa" class="text-sm font-medium text-[#808080] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-white transition-colors hidden sm:inline">Hermes</a>
<button @click="toggleDark()" class="border border-[#d4d4d4] dark:border-[#404040] rounded-md px-2 py-1 text-base cursor-pointer hover:bg-[#fafafa] dark:hover:bg-[#2a2a2a] transition-colors shrink-0" x-text="dark ? '☀️' : '🌙'" :title="dark ? 'Light mode' : 'Dark mode'"></button>
</div> </div>
</nav> </nav>
<section class="hero"> <!-- ════════════════════════════════════ -->
<h1>Projects &amp; Services</h1> <!-- HERO -->
<p>Self-hosted tools and infrastructure powering the Ourpad home lab — 2 Proxmox nodes, 16 LXCs, 30+ services.</p> <!-- ════════════════════════════════════ -->
<section class="text-center py-12 sm:py-16 px-6 max-w-3xl mx-auto">
<h1 class="text-3xl sm:text-[42px] font-semibold tracking-[-2px] mb-3">Ourpad Infrastructure</h1>
<p class="text-[15px] sm:text-[17px] text-[#808080] dark:text-[#a0a0a0] leading-relaxed">
Live service dashboard <span class="text-[#d4d4d4] dark:text-[#404040]">·</span> Infrastructure source of truth
<span class="text-[#d4d4d4] dark:text-[#404040]">·</span> <span class="font-mono text-sm">project-directory + network-inventory</span>
</p>
</section> </section>
<!-- PROJECTS --> <!-- ════════════════════════════════════ -->
<div class="section"> <!-- NETWORK TOPOLOGY DIAGRAM -->
<div class="section-header"><h2>Projects</h2><span class="badge">8</span></div> <!-- ════════════════════════════════════ -->
<div class="grid"> <div class="max-w-[900px] mx-auto px-6 pb-8">
<div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-xl p-5 overflow-x-auto">
<svg viewBox="0 0 820 340" class="w-full h-auto" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#d4d4d4"/>
</marker>
<marker id="arrow-dark" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#404040"/>
</marker>
</defs>
<a href="https://gitea.ourpad.casa/shawn/pass-vault" class="card"> <!-- Cloud / Internet -->
<div class="card-header"><div class="card-icon" style="background:#fef3c7;">🔐</div><span class="card-title">Pass Vault</span></div> <rect x="320" y="5" width="160" height="36" rx="18" class="topo-pill"/>
<p class="card-desc">Browser-based password manager — GPG-encrypted pass store with dark-themed web UI. Mobile responsive, systemd service.</p> <text x="400" y="28" text-anchor="middle" fill="#0068d6" font-size="12" font-family="Geist, sans-serif" font-weight="500" class="dark:fill-blue-400">☁️ Internet 68.202.6.107</text>
<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"> <!-- Cloudflare Badge -->
<div class="card-header"><div class="card-icon" style="background:#e0f2fe;">📍</div><span class="card-title">Canteen Asset Tracker</span></div> <rect x="295" y="50" width="90" height="20" rx="10" fill="#fef3c7"/>
<p class="card-desc">Mobile webapp for tracking canteen assets — machine-id scanning, OCR, GPS check-ins, geofences, CSV export.</p> <text x="340" y="64" text-anchor="middle" fill="#92400e" font-size="9" font-family="Geist, sans-serif" font-weight="500">🔒 Cloudflare Proxy</text>
<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"> <!-- Direct Badge -->
<div class="card-header"><div class="card-icon" style="background:#fce7f3;">🌐</div><span class="card-title">Canteen Web App</span></div> <rect x="415" y="50" width="90" height="20" rx="10" fill="#e0f2fe"/>
<p class="card-desc">Full-stack frontend for canteen management — login, navigation, asset CRUD, and reporting interface.</p> <text x="460" y="64" text-anchor="middle" fill="#0369a1" font-size="9" font-family="Geist, sans-serif" font-weight="500">🔓 Direct</text>
<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"> <!-- Arrow: Internet → NPMplus -->
<div class="card-header"><div class="card-icon" style="background:#f0fdf4;">📍</div><span class="card-title">Photo Geotagger</span></div> <line x1="400" y1="72" x2="400" y2="95" class="topo-line" marker-end="url(#arrow)"/>
<p class="card-desc">Batch GPS updater for canteen assets — EXIF GPS extraction, OCR machine ID scanning, Leaflet map review, systemd service.</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"> <!-- NPMplus Box -->
<div class="card-header"><div class="card-icon" style="background:#fef2f2;">🎛️</div><span class="card-title">Raspberry Pi Mixer</span></div> <rect x="320" y="95" width="160" height="42" rx="8" class="topo-box"/>
<p class="card-desc">Custom Linux distro for RPi4B — low-latency digital mixer with VST/LV2 plugins, touchscreen UI, web control, MIDI, recording, and streaming. Boots from SD, USB SSD, or NVMe HAT.</p> <text x="400" y="115" text-anchor="middle" fill="#171717" font-size="13" font-family="Geist, sans-serif" font-weight="600" class="dark:fill-[#e5e5e5]">🌐 NPMplus Reverse Proxy</text>
<div class="card-meta"><span class="tag">Embedded Linux</span><span class="tag">Audio DSP</span><span class="tag active">active</span></div> <text x="400" y="130" text-anchor="middle" fill="#808080" font-size="10" font-family="Geist Mono, monospace">192.168.0.110 · ourpad N150</text>
</a>
<a href="https://gitea.ourpad.casa/shawn/project-directory" class="card"> <!-- Arrow: NPMplus → PVE -->
<div class="card-header"><div class="card-icon" style="background:#ecfdf5;">📂</div><span class="card-title">Project Directory</span></div> <line x1="400" y1="137" x2="400" y2="160" class="topo-line" marker-end="url(#arrow)"/>
<p class="card-desc">This page — a living index of all Ourpad projects, services, and infrastructure. Served via NPMplus.</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"> <!-- PVE Box -->
<div class="card-header"><div class="card-icon" style="background:#ede9fe;">🤖</div><span class="card-title">Hermes Agent</span></div> <rect x="295" y="160" width="210" height="52" rx="8" class="topo-box"/>
<p class="card-desc">AI agent framework — 3 profiles, cron jobs, kanban boards, MCP tools, multi-model delegation. The brain of the lab.</p> <text x="400" y="180" text-anchor="middle" fill="#171717" font-size="13" font-family="Geist, sans-serif" font-weight="600" class="dark:fill-[#e5e5e5]">🖥️ PVE Host (i5-6500 · 31GB)</text>
<div class="card-meta"><span class="tag">Python</span><span class="tag">AI/ML</span><span class="tag active">active</span></div> <text x="400" y="195" text-anchor="middle" fill="#808080" font-size="10" font-family="Geist Mono, monospace">192.168.0.144 · 15 LXC + 1 VM · No Tailscale</text>
</a>
<a href="https://obsidian.md" class="card"> <!-- Arrow: PVE → VM 110 (left path) -->
<div class="card-header"><div class="card-icon" style="background:#f1f5f9;">📝</div><span class="card-title">Obsidian Vault</span></div> <line x1="345" y1="212" x2="295" y2="240" class="topo-line" marker-end="url(#arrow)"/>
<p class="card-desc">Knowledge management — three-layer vault (Inbox → Notes → Archive), 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>
<!-- Arrow: PVE → Docker CT 100 (right path) -->
<line x1="455" y1="212" x2="465" y2="240" class="topo-line" marker-end="url(#arrow)"/>
<!-- VM 110 Box (this server) -->
<rect x="120" y="240" width="190" height="82" rx="8" class="topo-box"/>
<text x="215" y="258" text-anchor="middle" fill="#171717" font-size="12" font-family="Geist, sans-serif" font-weight="600" class="dark:fill-[#e5e5e5]">🖥️ VM 110 — This Server</text>
<text x="215" y="273" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">192.168.0.127 (LAN)</text>
<text x="215" y="286" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">100.89.190.113 (Tailscale)</text>
<text x="215" y="299" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">68.202.6.107 (Public)</text>
<text x="215" y="314" text-anchor="middle" fill="#808080" font-size="8" font-family="Geist, sans-serif">Hermes · Canteen · Dashboards · News</text>
<!-- Docker CT 100 Box -->
<rect x="395" y="240" width="190" height="82" rx="8" class="topo-box"/>
<text x="490" y="258" text-anchor="middle" fill="#171717" font-size="12" font-family="Geist, sans-serif" font-weight="600" class="dark:fill-[#e5e5e5]">🐳 Docker CT 100</text>
<text x="490" y="273" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">192.168.0.223 (LAN)</text>
<text x="490" y="286" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">No Tailscale (via PVE)</text>
<text x="490" y="299" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace"></text>
<text x="490" y="314" text-anchor="middle" fill="#808080" font-size="8" font-family="Geist, sans-serif">Gitea · Pass Vault</text>
<!-- Ourpad N150 Box (bottom right) -->
<rect x="610" y="240" width="190" height="82" rx="8" class="topo-box"/>
<text x="705" y="258" text-anchor="middle" fill="#171717" font-size="12" font-family="Geist, sans-serif" font-weight="600" class="dark:fill-[#e5e5e5]">🖧 Ourpad N150</text>
<text x="705" y="273" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">192.168.0.110 (LAN)</text>
<text x="705" y="286" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace">No Tailscale</text>
<text x="705" y="299" text-anchor="middle" fill="#808080" font-size="9" font-family="Geist Mono, monospace"></text>
<text x="705" y="314" text-anchor="middle" fill="#808080" font-size="8" font-family="Geist, sans-serif">NPMplus · Jellyfin · cf-ddns</text>
<!-- Arrow: PVE → Ourpad N150 (not directly, but via network) -->
<line x1="505" y1="212" x2="705" y2="240" class="topo-line" marker-end="url(#arrow)"/>
<!-- Tailscale overlay box -->
<rect x="550" y="5" width="140" height="36" rx="18" fill="#ede9fe"/>
<text x="620" y="28" text-anchor="middle" fill="#6d28d9" font-size="12" font-family="Geist, sans-serif" font-weight="500">🔗 Tailscale 100.x.x.x</text>
<!-- Dashed line from VM 110 to Tailscale -->
<line x1="215" y1="240" x2="550" y2="23" stroke="#6d28d9" stroke-width="1.5" stroke-dasharray="4,3" fill="none" opacity="0.5"/>
<line x1="620" y1="41" x2="620" y2="240" stroke="#6d28d9" stroke-width="1" stroke-dasharray="4,3" fill="none" opacity="0.4"/>
<!-- Legend: 3 address types -->
<rect x="30" y="5" width="180" height="72" rx="6" fill="none" stroke="#00000010" stroke-width="1" class="dark:stroke-white/5"/>
<text x="40" y="22" fill="#808080" font-size="9" font-family="Geist, sans-serif" font-weight="600">Access Legend</text>
<rect x="40" y="30" width="8" height="8" rx="2" fill="#0072f5"/>
<text x="53" y="38" fill="#808080" font-size="9" font-family="Geist Mono, monospace">External (domain / public IP)</text>
<rect x="40" y="46" width="8" height="8" rx="2" fill="#808080"/>
<text x="53" y="54" fill="#808080" font-size="9" font-family="Geist Mono, monospace">LAN (192.168.0.x)</text>
<rect x="40" y="62" width="8" height="8" rx="2" fill="#6d28d9"/>
<text x="53" y="70" fill="#808080" font-size="9" font-family="Geist Mono, monospace">Tailscale (100.x.x.x)</text>
</svg>
</div> </div>
</div> </div>
<!-- PUBLIC SERVICES --> <!-- ════════════════════════════════════ -->
<div class="alt-bg"> <!-- QUICK STATS BAR -->
<div class="section"> <!-- ════════════════════════════════════ -->
<div class="section-header"><h2>Public Services</h2><span class="badge">22</span></div> <div class="max-w-[1300px] mx-auto px-6 pb-8">
<p style="font-size:13px;color:var(--gray-400);margin:-16px 0 24px;">All accessible via <code style="background:var(--gray-50);padding:1px 6px;border-radius:3px;">*.ourpad.casa</code> behind NPMplus + Cloudflare</p> <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div class="grid-4"> <div class="bg-[#fafafa] dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg py-4 px-3">
<div class="text-2xl font-semibold" x-text="hosts.length">0</div>
<a data-svc="gitea" class="svc-card"><span class="svc-icon">🦊</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Gitea</div><div class="svc-desc">Self-hosted Git — repos, issues, CI</div></div></a> <div class="text-xs text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider font-medium mt-0.5">Proxmox Hosts</div>
<a data-svc="pass-vault" class="svc-card"><span class="svc-icon">🔐</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Pass Vault</div><div class="svc-desc">Web-based secret management</div></div></a> </div>
<a data-svc="projects" class="svc-card"><span class="svc-icon">📂</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Project Directory</div><div class="svc-desc">This page</div></div></a> <div class="bg-[#fafafa] dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg py-4 px-3">
<a href="https://dashboard.ourpad.casa" class="svc-card"><span class="svc-icon">🏠</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Heimdall Dashboard</div><div class="svc-desc">Service landing page</div></div></a> <div class="text-2xl font-semibold" x-text="hosts.reduce((a,h)=>a+(h.lxcs?.length||0),0)">0</div>
<a data-svc="sonarr" class="svc-card"><span class="svc-icon">📺</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Sonarr</div><div class="svc-desc">TV show automation</div></div></a> <div class="text-xs text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider font-medium mt-0.5">Containers</div>
<a data-svc="radarr" class="svc-card"><span class="svc-icon">🎬</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Radarr</div><div class="svc-desc">Movie automation</div></div></a> </div>
<a data-svc="jellyseerr" class="svc-card"><span class="svc-icon">🎞️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Jellyseerr</div><div class="svc-desc">Media requests</div></div></a> <div class="bg-[#fafafa] dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg py-4 px-3">
<a data-svc="deluge" class="svc-card"><span class="svc-icon">📥</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Deluge</div><div class="svc-desc">Torrent client</div></div></a> <div class="text-2xl font-semibold" x-text="onlineCount">0</div>
<a data-svc="jellyfin" class="svc-card"><span class="svc-icon">▶️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Jellyfin</div><div class="svc-desc">Media streaming</div></div></a> <div class="text-xs text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider font-medium mt-0.5">Services Online</div>
<a data-svc="obsidian" class="svc-card"><span class="svc-icon">🔄</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Obsidian LiveSync</div><div class="svc-desc">CouchDB sync server</div></div></a> </div>
<a data-svc="inventree" class="svc-card"><span class="svc-icon">📦</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Inventree</div><div class="svc-desc">Inventory management</div></div></a> <div class="bg-[#fafafa] dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg py-4 px-3">
<a data-svc="npmplus" class="svc-card"><span class="svc-icon">🌐</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>NPMplus</div><div class="svc-desc">Reverse proxy admin</div></div></a> <div class="text-2xl font-semibold">33</div>
<a data-svc="proxmox" class="svc-card"><span class="svc-icon">🖥️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Proxmox Admin</div><div class="svc-desc">VE management UI</div></div></a> <div class="text-xs text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider font-medium mt-0.5">Mapped Ports</div>
<a data-svc="media" class="svc-card"><span class="svc-icon">💾</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Proxmox Media</div><div class="svc-desc">Secondary VE host</div></div></a>
<a data-svc="rustdesk" class="svc-card"><span class="svc-icon">🖧</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>RustDesk</div><div class="svc-desc">Remote desktop relay</div></div></a>
<a data-svc="canteen" class="svc-card"><span class="svc-icon">🏗️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Canteen App</div><div class="svc-desc">Asset tracker (WIP)</div></div></a>
<a data-svc="geotagger" class="svc-card"><span class="svc-icon">📍</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Photo Geotagger</div><div class="svc-desc">Batch GPS updater</div></div></a>
<a data-svc="hermes" class="svc-card"><span class="svc-icon">🤖</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Hermes Dashboard</div><div class="svc-desc">Agent management UI</div></div></a>
<a data-svc="coder" class="svc-card"><span class="svc-icon">💻</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Hermes Coder</div><div class="svc-desc">Agent dashboard (coder profile)</div></div></a>
<a data-svc="system-db" class="svc-card"><span class="svc-icon">📊</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>System Dashboard</div><div class="svc-desc">Real-time server monitoring</div></div></a>
<a data-svc="canteen-admin" class="svc-card"><span class="svc-icon">🛠️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Canteen Admin</div><div class="svc-desc">Asset tracker admin panel</div></div></a>
<a data-svc="status" class="svc-card"><span class="svc-icon">📡</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Network Status</div><div class="svc-desc">Live service health monitor</div></div></a>
</div> </div>
</div> </div>
</div> </div>
<!-- INTERNAL SERVICES --> <!-- ════════════════════════════════════ -->
<div class="alt-bg"> <!-- LIVE DASHBOARD -->
<div class="section"> <!-- ════════════════════════════════════ -->
<div class="section-header"><h2>Internal Services</h2><span class="badge">10</span></div> <div class="max-w-[1300px] mx-auto px-6 pb-6">
<p style="font-size:13px;color:var(--gray-400);margin:-16px 0 24px;">Not publicly exposed — accessible via Tailscale or internal network only</p> <div class="flex items-center gap-3 mb-5">
<div class="grid-4"> <div class="h-5 w-1 bg-[#0072f5] dark:bg-[#5ba4ff] rounded-full"></div>
<h2 class="text-lg font-semibold tracking-tight">📊 Live Dashboard</h2>
<span class="svc-card svc-internal"><span class="svc-icon">🔍</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Prowlarr</div><div class="svc-desc">Indexer aggregator (CT 104)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">📚</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Readarr</div><div class="svc-desc">Book management (CT 108)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">⚙️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Configarr</div><div class="svc-desc">Config sync (CT 107)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">📦</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Unpackerr</div><div class="svc-desc">RAR extraction (CT 114)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">💬</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Element Synapse</div><div class="svc-desc">Matrix server (CT 112)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">🐳</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Portainer</div><div class="svc-desc">Docker management (CT 100:9443)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">🧠</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Hermes WebUI</div><div class="svc-desc">Agent dashboard (CT 102:8787)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">⌨️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Deskbrid</div><div class="svc-desc">Desktop automation</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">☁️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Cloudflare DDNS</div><div class="svc-desc">Dynamic DNS updater (CT 105)</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">🔗</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>Tailscale</div><div class="svc-desc">Mesh VPN — 2 nodes</div></div></span>
</div>
</div>
</div> </div>
<!-- INFRASTRUCTURE --> <!-- Proxmox Hosts -->
<div class="alt-bg"> <div class="mb-6">
<div class="section"> <div class="flex items-center gap-2 mb-3">
<div class="section-header"><h2>Infrastructure</h2></div> <h3 class="text-sm font-semibold text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider">🖥️ Proxmox Hosts</h3>
<div class="grid"> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template x-for="h in hosts" :key="h.id">
<div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg p-5">
<template x-if="h.error">
<div><h4 class="font-semibold flex items-center gap-2"><span>🖥️</span><span x-text="h.name"></span></h4><span class="text-sm italic text-[#808080]" x-text="'Error: '+h.error"></span></div>
</template>
<template x-if="!h.error">
<div>
<h4 class="font-semibold flex items-center gap-2 mb-3"><span>🖥️</span><span x-text="h.name"></span><span class="ml-auto text-xs text-[#808080] dark:text-[#a0a0a0] font-mono" x-text="h.node.pveversion + ' · ' + h.node.uptime_hrs + 'h'"></span></h4>
<div class="space-y-2">
<div class="flex items-center gap-3"><span class="text-xs font-medium text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider w-8 shrink-0">CPU</span><div class="flex-1 h-2 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full transition-all duration-1000" :style="'width:'+Math.min(h.node.cpu,100)+'%;background:'+barColor(h.node.cpu)"></div></div><span class="font-mono text-xs text-[#808080] dark:text-[#a0a0a0] w-12 text-right" x-text="h.node.cpu+'%'"></span></div>
<div class="flex items-center gap-3"><span class="text-xs font-medium text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider w-8 shrink-0">RAM</span><div class="flex-1 h-2 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full transition-all duration-1000" :style="'width:'+h.node.memory.pct+'%;background:'+barColor(h.node.memory.pct)"></div></div><span class="font-mono text-xs text-[#808080] dark:text-[#a0a0a0] w-20 text-right" x-text="h.node.memory.used_mb+'/'+h.node.memory.total_mb+' MB'"></span></div>
<div class="flex items-center gap-3"><span class="text-xs font-medium text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider w-8 shrink-0">DISK</span><div class="flex-1 h-2 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full transition-all duration-1000" :style="'width:'+h.node.disk.pct+'%;background:'+barColor(h.node.disk.pct)"></div></div><span class="font-mono text-xs text-[#808080] dark:text-[#a0a0a0] w-20 text-right" x-text="h.node.disk.used_gb+'/'+h.node.disk.total_gb+' GB'"></span></div>
</div>
<div class="mt-4 pt-3 border-t border-black/5 dark:border-white/5">
<div class="grid grid-cols-[1.4fr_0.5fr_0.5fr_0.5fr] gap-1.5 items-center text-[11px] text-[#808080] dark:text-[#a0a0a0] font-semibold uppercase tracking-wider pb-1">
<span>Container</span><span>CPU</span><span>RAM</span><span>Disk</span>
</div>
<template x-for="ct in (h.lxcs||[])" :key="ct.vmid">
<div class="grid grid-cols-[1.4fr_0.5fr_0.5fr_0.5fr] gap-1.5 items-center text-xs py-0.5" :class="ct.status!='running'?'opacity-40':''">
<span class="font-medium truncate text-xs" x-text="'CT'+ct.vmid+'.'+ct.name"></span>
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="ct.cpu_pct+'%'"></span>
<div class="flex items-center gap-1"><div class="flex-1 h-1.5 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full" :style="'width:'+ct.mem_pct+'%;background:'+barColor(ct.mem_pct)"></div></div><span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="ct.mem_mb+'M'"></span></div>
<div class="flex items-center gap-1"><div class="flex-1 h-1.5 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full" :style="'width:'+ct.disk_pct+'%;background:'+barColor(ct.disk_pct)"></div></div><span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="ct.disk_gb+'G'"></span></div>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<span class="card nolink"> <!-- Docker -->
<div class="card-header"><div class="card-icon" style="background:#fef2f2;">🖥️</div><span class="card-title">Primary Proxmox</span></div> <div class="mb-6" x-show="dockerContainers.length" x-cloak>
<p class="card-desc"><strong>192.168.0.144</strong> — VE 8.4, 31GB RAM, 14 LXCs. Docker LXC, arr stack, NAS, Hermes.</p> <div class="flex items-center gap-2 mb-3">
<div class="card-meta"><span class="tag">pve</span><span class="tag">ZFS</span></div> <h3 class="text-sm font-semibold text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider">🐳 Docker (CT 100)</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="dockerContainers.length"></span>
</div>
<div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg p-4 overflow-x-auto">
<div class="grid grid-cols-[1.6fr_0.5fr_0.6fr_1.2fr_1.2fr_0.3fr] gap-2 items-center text-[11px] text-[#808080] dark:text-[#a0a0a0] font-semibold uppercase tracking-wider pb-2 border-b border-black/5 dark:border-white/5 mb-2 min-w-[480px]">
<span>Container</span><span>CPU</span><span>RAM</span><span>Network ↓ / ↑</span><span>Block ↓ / ↑</span><span>PIDs</span>
</div>
<template x-for="c in dockerContainers" :key="c.name">
<div class="grid grid-cols-[1.6fr_0.5fr_0.6fr_1.2fr_1.2fr_0.3fr] gap-2 items-center text-xs py-1 border-b border-[#fafafa] dark:border-[#2a2a2a] last:border-0 min-w-[480px]">
<span class="font-medium truncate" x-text="c.name" :title="c.name"></span>
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="c.cpu+'%'"></span>
<div class="flex items-center gap-1"><div class="flex-1 h-1.5 bg-[#ebebeb] dark:bg-[#333] rounded-full overflow-hidden"><div class="h-full rounded-full" :style="'width:'+Math.min(parseFloat(c.mem_pct)||0,100)+'%;background:'+barColor(parseFloat(c.mem_pct)||0)"></div></div><span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="c.mem_used"></span></div>
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="c.net_in+' / '+c.net_out"></span>
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="c.blk_in+' / '+c.blk_out"></span>
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0]" x-text="c.pids"></span>
</div>
</template>
</div>
</div>
<!-- Services with live status + 3-address expand -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<h3 class="text-sm font-semibold text-[#808080] dark:text-[#a0a0a0] uppercase tracking-wider">🌐 Service Health</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="publicServices.length + internalServices.length"></span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2" x-data="{ expanded: null }">
<template x-for="s in [...publicServices, ...internalServices]" :key="s.id">
<div>
<div class="flex items-center gap-2 px-3 py-2.5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg cursor-pointer hover:border-[#808080]/30 dark:hover:border-[#808080]/30 transition-colors"
@click="expanded = expanded === s.id ? null : s.id">
<span x-text="s.icon" class="text-base shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span x-show="!s.nostatus" class="shrink-0">
<span class="inline-block w-2 h-2 rounded-full" :class="'bg-'+((serviceStatus[s.id]||'checking')=='online'?'[#22c55e]':(serviceStatus[s.id]=='offline'?'[#ef4444]':(serviceStatus[s.id]=='checking'?'[#f59e0b] animate-pulse-slow':'[#f59e0b]'))"></span>
</span> </span>
<span x-show="s.nostatus" class="shrink-0"><span class="inline-block w-2 h-2 rounded-full bg-[#d4d4d4]"></span></span>
<span class="card nolink"> <span class="text-xs font-medium truncate" x-text="s.name"></span>
<div class="card-header"><div class="card-icon" style="background:#eff6ff;">🌐</div><span class="card-title">Secondary Proxmox</span></div> </div>
<p class="card-desc"><strong>192.168.0.110</strong> — NPMplus reverse proxy, Jellyfin, Cloudflare DDNS. 3 LXCs.</p> <div class="text-[11px] text-[#808080] dark:text-[#a0a0a0] truncate" x-text="s.desc"></div>
<div class="card-meta"><span class="tag">NPMplus</span><span class="tag">Edge</span></div> </div>
</span> </div>
<!-- Expanded: 3-address card -->
<span class="card nolink"> <div x-show="expanded === s.id" x-cloak x-transition:enter.duration.200ms class="px-3 py-2.5 bg-[#fafafa] dark:bg-[#2a2a2a] rounded-b-lg border-x border-b border-black/5 dark:border-white/5 text-xs text-[#808080] dark:text-[#a0a0a0] space-y-1">
<div class="card-header"><div class="card-icon" style="background:#f0fdf4;">💾</div><span class="card-title">NAS Storage</span></div> <div class="flex gap-2 items-start">
<p class="card-desc"><strong>3.6TB ext4</strong> at /mnt/nas — bind-mounted into all arr LXCs. Media library, torrents, backups.</p> <span class="font-semibold uppercase tracking-wider text-[9px] bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-1 py-0.5 rounded shrink-0 mt-0.5">EXT</span>
<div class="card-meta"><span class="tag">/dev/sdb</span><span class="tag">ext4</span></div> <template x-if="s.external">
</span> <a :href="s.external" target="_blank" class="font-mono text-[11px] text-[#0072f5] dark:text-[#5ba4ff] no-underline hover:underline truncate" x-text="s.external"></a>
</template>
<span class="card nolink"> <template x-if="!s.external">
<div class="card-header"><div class="card-icon" style="background:#f5f3ff;">🔒</div><span class="card-title">Networking</span></div> <span class="font-mono text-[11px] italic opacity-50">no external access</span>
<p class="card-desc"><strong>Cloudflare</strong> DNS + proxy (68.202.6.107) → NPMplus (192.168.0.115) → internal services. <strong>Tailscale</strong> mesh for remote access.</p> </template>
<div class="card-meta"><span class="tag">ourpad.casa</span><span class="tag">TLS 1.3</span></div> </div>
</span> <div class="flex gap-2 items-start">
<span class="font-semibold uppercase tracking-wider text-[9px] bg-[#ebebeb] dark:bg-[#333] text-[#808080] dark:text-[#a0a0a0] px-1 py-0.5 rounded shrink-0 mt-0.5">LAN</span>
<template x-if="s.lan">
<span class="font-mono text-[11px]" x-text="s.lan"></span>
</template>
<template x-if="!s.lan">
<span class="font-mono text-[11px] italic opacity-50"></span>
</template>
</div>
<div class="flex gap-2 items-start">
<span class="font-semibold uppercase tracking-wider text-[9px] bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-1 py-0.5 rounded shrink-0 mt-0.5">TS</span>
<template x-if="s.ts">
<span class="font-mono text-[11px]" x-text="s.ts"></span>
</template>
<template x-if="!s.ts">
<span class="font-mono text-[11px] italic opacity-50">not on Tailscale</span>
</template>
</div>
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
<!-- LXC INVENTORY --> <!-- ════════════════════════════════════ -->
<div class="alt-bg"> <!-- SECTION DIVIDER -->
<div class="section"> <!-- ════════════════════════════════════ -->
<div class="section-header"><h2>LXC Inventory</h2><span class="badge">17</span></div> <div class="border-t border-black/5 dark:border-white/5 my-6 max-w-[1300px] mx-auto px-6"></div>
<p style="font-size:13px;color:var(--gray-400);margin:-16px 0 24px;">All containers across both Proxmox hosts</p>
<div class="grid-4"> <!-- ════════════════════════════════════ -->
<span class="svc-card svc-internal"><span class="svc-icon">🐳</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 100 docker</div><div class="svc-desc">192.168.0.223 · Gitea, Portainer, LiveSync</div></div></span> <!-- SOURCE OF TRUTH -->
<span class="svc-card svc-internal"><span class="svc-icon">🐧</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 101 debian</div><div class="svc-desc">Debian utility container</div></div></span> <!-- ════════════════════════════════════ -->
<span class="svc-card svc-internal"><span class="svc-icon">🧠</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 102 hermes</div><div class="svc-desc">100.89.190.113 · Hermes Agent host</div></div></span> <div class="max-w-[1300px] mx-auto px-6 pb-12">
<span class="svc-card svc-internal"><span class="svc-icon">📺</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 103 sonarr</div><div class="svc-desc">192.168.0.178 · Sonarr</div></div></span> <div class="flex items-center gap-3 mb-5">
<span class="svc-card svc-internal"><span class="svc-icon">🔍</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 104 prowlarr</div><div class="svc-desc">192.168.0.225 · Prowlarr</div></div></span> <div class="h-5 w-1 bg-[#22c55e] rounded-full"></div>
<span class="svc-card svc-internal"><span class="svc-icon">🎬</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 105 radarr</div><div class="svc-desc">192.168.0.123 · Radarr</div></div></span> <h2 class="text-lg font-semibold tracking-tight">📋 Source of Truth</h2>
<span class="svc-card svc-internal"><span class="svc-icon">🎞️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 106 jellyseerr</div><div class="svc-desc">192.168.0.229 · Jellyseerr</div></div></span> <span class="inline-flex items-center bg-[#ecfdf5] dark:bg-green-900/20 text-[#065f46] dark:text-green-400 text-xs font-medium px-2 h-5 rounded-full">live from network-inventory.yaml</span>
<span class="svc-card svc-internal"><span class="svc-icon">⚙️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 107 configarr</div><div class="svc-desc">192.168.0.238 · Configarr</div></div></span> </div>
<span class="svc-card svc-internal"><span class="svc-icon">📚</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 108 readarr</div><div class="svc-desc">192.168.0.193 · Readarr</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">📥</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 109 deluge</div><div class="svc-desc">192.168.0.243 · Deluge</div></div></span> <div x-show="!inv" class="text-sm italic text-[#808080] dark:text-[#a0a0a0] py-8 text-center">Loading inventory...</div>
<span class="svc-card svc-internal"><span class="svc-icon">💬</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 112 elementsynapse</div><div class="svc-desc">192.168.0.220 · Matrix</div></div></span>
<span class="svc-card svc-internal"><span class="svc-icon">📦</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 114 unpackerr</div><div class="svc-desc">192.168.0.119 · Unpackerr</div></div></span> <template x-if="inv && !inv.error">
<span class="svc-card svc-internal"><span class="svc-icon">🏠</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 115 heimdall</div><div class="svc-desc">192.168.0.213 · Dashboard</div></div></span> <div class="space-y-6">
<span class="svc-card svc-internal"><span class="svc-icon">📋</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 116 inventree</div><div class="svc-desc">192.168.0.132 · Inventory</div></div></span>
<!-- Secondary host --> <!-- ──────── CREDENTIALS ──────── -->
<span class="svc-card svc-internal"><span class="svc-icon">🌐</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 102 npmplus</div><div class="svc-desc">192.168.0.115 · Reverse proxy</div></div></span> <div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg overflow-hidden" x-data="{ credOpen: true }">
<span class="svc-card svc-internal"><span class="svc-icon">▶️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 104 jellyfin</div><div class="svc-desc">192.168.0.125 · Media server</div></div></span> <div class="flex items-center justify-between px-5 py-3.5 bg-[#fafafa] dark:bg-[#2a2a2a] border-b border-black/5 dark:border-white/5 cursor-pointer select-none" @click="credOpen = !credOpen">
<span class="svc-card svc-internal"><span class="svc-icon">☁️</span><div><div class="svc-name"><span class="svc-status"><span class="status-dot checking"></span></span>CT 105 cloudflare-ddns</div><div class="svc-desc">Dynamic DNS updater</div></div></span> <div class="flex items-center gap-2.5">
<span class="text-lg">🔑</span>
<h3 class="font-semibold text-sm">Credentials</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="(inv.credentials?.entries?.length||0)+' entries'"></span>
</div>
<span class="text-xs text-[#808080] dark:text-[#a0a0a0] transition-transform duration-200" :class="credOpen ? 'rotate-180' : ''"></span>
</div>
<div x-show="credOpen">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-semibold uppercase tracking-wider border-b border-black/5 dark:border-white/5 bg-white dark:bg-[#222]">
<th class="text-left px-5 py-3 w-[35%]">Service</th>
<th class="text-left px-5 py-3 w-[25%]">Pass Path</th>
<th class="text-left px-5 py-3 w-[15%] hidden sm:table-cell">User</th>
<th class="text-left px-5 py-3 w-[25%] hidden md:table-cell">Notes</th>
</tr>
</thead>
<tbody>
<template x-for="(entry, i) in (inv.credentials?.entries||[])" :key="i">
<tr class="border-b border-[#fafafa] dark:border-[#2a2a2a] last:border-0 hover:bg-[#fafafa] dark:hover:bg-[#2a2a2a]">
<td class="px-5 py-3 font-medium text-sm" x-text="entry.service"></td>
<td class="px-5 py-3">
<a :href="'https://pass.ourpad.casa/'+entry.pass_path" target="_blank" class="font-mono text-xs text-[#0072f5] dark:text-[#5ba4ff] no-underline hover:underline bg-[#ebf5ff] dark:bg-blue-900/20 px-1.5 py-0.5 rounded" x-text="entry.pass_path"></a>
</td>
<td class="px-5 py-3 text-[#808080] dark:text-[#a0a0a0] text-xs hidden sm:table-cell font-mono" x-text="entry.username||'—'"></td>
<td class="px-5 py-3 text-[#808080] dark:text-[#a0a0a0] text-xs hidden md:table-cell" x-text="entry.note||'—'"></td>
</tr>
</template>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<footer> <!-- ──────── PORT MAP ──────── -->
<p>Built with <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> · Served via <a href="https://gitea.ourpad.casa">Gitea</a> + NPMplus · <a href="https://gitea.ourpad.casa/shawn/project-directory">Source</a></p> <div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg overflow-hidden" x-data="{ portOpen: true }">
<div class="flex items-center justify-between px-5 py-3.5 bg-[#fafafa] dark:bg-[#2a2a2a] border-b border-black/5 dark:border-white/5 cursor-pointer select-none" @click="portOpen = !portOpen">
<div class="flex items-center gap-2.5">
<span class="text-lg">📡</span>
<h3 class="font-semibold text-sm">Port Map</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="Object.keys(inv.port_map||{}).length+' ports'"></span>
</div>
<span class="text-xs text-[#808080] dark:text-[#a0a0a0] transition-transform duration-200" :class="portOpen ? 'rotate-180' : ''"></span>
</div>
<div x-show="portOpen" x-cloak>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-semibold uppercase tracking-wider border-b border-black/5 dark:border-white/5">
<th class="text-left px-5 py-3 w-[10%]">Port</th>
<th class="text-left px-5 py-3 w-[65%]">Service</th>
<th class="text-left px-5 py-3 w-[25%] hidden sm:table-cell">Access</th>
</tr>
</thead>
<tbody>
<template x-for="(desc, port) in sortPorts(inv.port_map||{})" :key="port">
<tr class="border-b border-[#fafafa] dark:border-[#2a2a2a] last:border-0 hover:bg-[#fafafa] dark:hover:bg-[#2a2a2a]">
<td class="px-5 py-2"><code class="font-mono text-sm font-bold bg-[#ebf5ff] dark:bg-blue-900/20 text-[#0068d6] dark:text-blue-400 px-2 py-0.5 rounded" x-text="port"></code></td>
<td class="px-5 py-2 text-sm" x-text="desc"></td>
<td class="px-5 py-2 text-xs text-[#808080] dark:text-[#a0a0a0] hidden sm:table-cell">
<span x-text="portAccessLabel(port, inv)"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- ──────── SERVICES (detailed YAML) ──────── -->
<div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg overflow-hidden" x-data="{ svcOpen: true }">
<div class="flex items-center justify-between px-5 py-3.5 bg-[#fafafa] dark:bg-[#2a2a2a] border-b border-black/5 dark:border-white/5 cursor-pointer select-none" @click="svcOpen = !svcOpen">
<div class="flex items-center gap-2.5">
<span class="text-lg">🔧</span>
<h3 class="font-semibold text-sm">Service Details</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="(inv.services?.length||0)+' services'"></span>
</div>
<span class="text-xs text-[#808080] dark:text-[#a0a0a0] transition-transform duration-200" :class="svcOpen ? 'rotate-180' : ''"></span>
</div>
<div x-show="svcOpen" x-cloak>
<div class="divide-y divide-black/5 dark:divide-white/5">
<template x-for="(svc, i) in (inv.services||[])" :key="i">
<div class="px-5 py-4 hover:bg-[#fafafa] dark:hover:bg-[#2a2a2a]">
<div class="flex items-start justify-between gap-3 mb-1">
<div class="flex items-center gap-2">
<span class="font-medium text-sm" x-text="svc.name"></span>
<span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded" x-text="svc.category||''"></span>
</div>
<div class="flex gap-1.5 shrink-0">
<template x-if="svc.port">
<code class="font-mono text-xs bg-[#ebf5ff] dark:bg-blue-900/20 text-[#0068d6] dark:text-blue-400 px-1.5 py-0.5 rounded" x-text="':'+svc.port"></code>
</template>
<template x-if="svc.auth_port">
<code class="font-mono text-xs bg-[#fef3c7] dark:bg-yellow-900/20 text-[#92400e] dark:text-yellow-400 px-1.5 py-0.5 rounded" x-text="'proxy:'+svc.auth_port"></code>
</template>
</div>
</div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] mb-2 leading-relaxed" x-text="svc.description||''"></p>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs">
<template x-if="svc.systemd">
<div><span class="text-[10px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider">systemd</span> <code class="font-mono text-xs" x-text="typeof svc.systemd==='string'?svc.systemd:'(multiple)'"></code></div>
</template>
<template x-if="svc.working_dir">
<div><span class="text-[10px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider">dir</span> <code class="font-mono text-xs" x-text="svc.working_dir"></code></div>
</template>
<template x-if="svc.db_path">
<div class="w-full"><span class="text-[10px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider">db</span> <code class="font-mono text-xs" x-text="svc.db_path"></code></div>
</template>
<template x-if="svc.tech_stack">
<div class="flex flex-wrap gap-1 mt-1">
<template x-for="tech in (Array.isArray(svc.tech_stack)?svc.tech_stack:[svc.tech_stack])" :key="tech">
<span class="font-mono text-[10px] text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded" x-text="tech"></span>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- ──────── PROXMOX HARDWARE ──────── -->
<div class="bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg overflow-hidden" x-data="{ hwOpen: true }">
<div class="flex items-center justify-between px-5 py-3.5 bg-[#fafafa] dark:bg-[#2a2a2a] border-b border-black/5 dark:border-white/5 cursor-pointer select-none" @click="hwOpen = !hwOpen">
<div class="flex items-center gap-2.5">
<span class="text-lg">🖥️</span>
<h3 class="font-semibold text-sm">Proxmox Hardware Specs</h3>
<span class="inline-flex items-center bg-[#ebf5ff] dark:bg-blue-900/30 text-[#0068d6] dark:text-blue-400 text-xs font-medium px-2 h-5 rounded-full" x-text="(inv.proxmox_hosts?.length||0)+' hosts'"></span>
</div>
<span class="text-xs text-[#808080] dark:text-[#a0a0a0] transition-transform duration-200" :class="hwOpen ? 'rotate-180' : ''"></span>
</div>
<div x-show="hwOpen" x-cloak>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-y md:divide-y-0 md:divide-x divide-black/5 dark:divide-white/5">
<template x-for="(host, i) in (inv.proxmox_hosts||[])" :key="i">
<div class="p-5">
<div class="flex items-center justify-between mb-4">
<h4 class="font-semibold" x-text="host.hostname"></h4>
<a :href="host.web_ui" target="_blank" class="text-[11px] text-[#0072f5] dark:text-[#5ba4ff] no-underline hover:underline">Web UI →</a>
</div>
<div class="space-y-2 text-sm">
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">Model</span><span class="font-mono text-xs" x-text="host.hardware?.model||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">CPU</span><span class="font-mono text-xs" x-text="host.hardware?.cpu||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">Cores</span><span class="font-mono text-xs" x-text="host.hardware?.cores||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">RAM</span><span class="font-mono text-xs" x-text="host.hardware?.ram||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">IP</span><span class="font-mono text-xs" x-text="host.local_ip||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">OS</span><span class="font-mono text-xs" x-text="host.os||'—'"></span></div>
<div class="flex gap-2"><span class="text-[11px] text-[#808080] dark:text-[#a0a0a0] font-medium uppercase tracking-wider min-w-[48px]">Boot</span><span class="font-mono text-xs" x-text="host.boot_disk_size+'GB'"></span></div>
</div>
<div class="mt-4 pt-3 border-t border-black/5 dark:border-white/5">
<div class="flex flex-wrap gap-1">
<template x-for="(storage, si) in (host.hardware?.storage||[])" :key="si">
<span class="font-mono text-[11px] text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded" x-text="storage"></span>
</template>
</div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] mt-3 leading-relaxed" x-text="host.notes||''"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<template x-if="inv && inv.error">
<div class="bg-[#fef2f2] dark:bg-red-900/20 border border-[#fecaca] dark:border-red-800 rounded-lg p-4 text-sm text-[#991b1b] dark:text-red-400">
⚠️ Error loading inventory: <span x-text="inv.error"></span>
</div>
</template>
</div>
<!-- ════════════════════════════════════ -->
<!-- PROJECT CARDS -->
<!-- ════════════════════════════════════ -->
<div class="max-w-[1300px] mx-auto px-6 pb-12">
<div class="border-t border-black/5 dark:border-white/5 pt-6 mb-6">
<div class="flex items-center gap-3 mb-5">
<div class="h-5 w-1 bg-[#f59e0b] rounded-full"></div>
<h2 class="text-lg font-semibold tracking-tight">🛠️ Projects</h2>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<a href="https://gitea.ourpad.casa/shawn/pass-vault" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#fef3c7]">🔐</div><span class="font-semibold text-sm">Pass Vault</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">Browser-based password manager — GPG-encrypted pass store.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded">FastAPI</span><span class="text-[10px] font-mono uppercase tracking-wider text-[#065f46] dark:text-green-400 bg-[#ecfdf5] dark:bg-green-900/20 px-1.5 py-0.5 rounded">active</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/canteen-asset-tracker" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#e0f2fe]">📍</div><span class="font-semibold text-sm">Canteen Asset Tracker</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">Machine-id scanning, OCR, GPS check-ins, geofences, CSV export.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded">FastAPI</span><span class="text-[10px] font-mono uppercase tracking-wider text-[#92400e] dark:text-yellow-400 bg-[#fef3c7] dark:bg-yellow-900/20 px-1.5 py-0.5 rounded">wip</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/canteen-web-app" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#fce7f3]">🌐</div><span class="font-semibold text-sm">Canteen Web App</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">Full-stack frontend — login, navigation, asset CRUD, reporting.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded">Frontend</span><span class="text-[10px] font-mono uppercase tracking-wider text-[#92400e] dark:text-yellow-400 bg-[#fef3c7] dark:bg-yellow-900/20 px-1.5 py-0.5 rounded">wip</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/canteen-seed-import" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#fef3c7]">🌱</div><span class="font-semibold text-sm">Canteen Seed Import</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">CSV parsing, GPS derivation, location extraction, sync to main app.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#808080] dark:text-[#a0a0a0] bg-[#fafafa] dark:bg-[#2a2a2a] px-1.5 py-0.5 rounded">Python</span><span class="text-[10px] font-mono uppercase tracking-wider text-[#92400e] dark:text-yellow-400 bg-[#fef3c7] dark:bg-yellow-900/20 px-1.5 py-0.5 rounded">wip</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/raspberry-pi-mixer" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#fef2f2]">🎛️</div><span class="font-semibold text-sm">Raspberry Pi Mixer</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">Custom Linux distro for RPi4B — low-latency digital mixer.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#065f46] dark:text-green-400 bg-[#ecfdf5] dark:bg-green-900/20 px-1.5 py-0.5 rounded">active</span></div>
</a>
<a href="https://gitea.ourpad.casa/shawn/project-directory" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#ecfdf5]">📂</div><span class="font-semibold text-sm">Project Directory</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">This page — living dashboard of all Ourpad projects and infrastructure.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#065f46] dark:text-green-400 bg-[#ecfdf5] dark:bg-green-900/20 px-1.5 py-0.5 rounded">active</span></div>
</a>
<a href="https://github.com/NousResearch/hermes-agent" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#ede9fe]">🤖</div><span class="font-semibold text-sm">Hermes Agent</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">AI agent framework — 5 profiles, cron jobs, kanban, MCP tools.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#065f46] dark:text-green-400 bg-[#ecfdf5] dark:bg-green-900/20 px-1.5 py-0.5 rounded">active</span></div>
</a>
<a href="https://obsidian.md" class="flex flex-col gap-2.5 p-5 bg-white dark:bg-[#222] border border-black/5 dark:border-white/5 rounded-lg no-underline text-inherit transition-all hover:border-[#808080]/30 dark:hover:border-[#808080]/30 hover:-translate-y-0.5">
<div class="flex items-center gap-2.5"><div class="w-8 h-8 rounded flex items-center justify-center shrink-0 bg-[#f1f5f9]">📝</div><span class="font-semibold text-sm">Obsidian Vault</span></div>
<p class="text-xs text-[#808080] dark:text-[#a0a0a0] leading-relaxed">Three-layer vault, LLM Wiki, daily notes, automated reviews.</p>
<div class="flex gap-1 flex-wrap mt-auto"><span class="text-[10px] font-mono uppercase tracking-wider text-[#065f46] dark:text-green-400 bg-[#ecfdf5] dark:bg-green-900/20 px-1.5 py-0.5 rounded">active</span></div>
</a>
</div>
</div>
<!-- ════════════════════════════════════ -->
<!-- FOOTER -->
<!-- ════════════════════════════════════ -->
<footer class="border-t border-black/5 dark:border-white/5 py-8 px-6 text-center">
<p class="text-sm text-[#808080] dark:text-[#a0a0a0]">
Built with <a href="https://github.com/NousResearch/hermes-agent" class="text-[#0072f5] dark:text-[#5ba4ff] no-underline hover:underline">Hermes Agent</a>
<span class="mx-2 text-[#d4d4d4] dark:text-[#404040]">·</span>
<a href="https://gitea.ourpad.casa/shawn/project-directory" class="text-[#0072f5] dark:text-[#5ba4ff] no-underline hover:underline">Source</a>
<span class="mx-2 text-[#d4d4d4] dark:text-[#404040]">·</span>
<span class="font-mono text-xs" x-text="new Date().toLocaleDateString('en-US', {year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})"></span>
</p>
</footer> </footer>
<script> <script>
(async function() { function dashboard() {
const STATUS_API = "/api/status"; return {
async function update() { dark: localStorage.getItem('dark') === 'true',
hosts: [],
dockerContainers: [],
serviceStatus: {},
publicServices: [],
internalServices: [],
inv: null,
pollTimers: [],
get onlineCount() {
return Object.values(this.serviceStatus).filter(s => s === 'online').length;
},
barColor(pct) {
if (pct > 90) return '#ef4444';
if (pct > 70) return '#f59e0b';
return '#22c55e';
},
toggleDark() {
this.dark = !this.dark;
localStorage.setItem('dark', String(this.dark));
document.documentElement.classList.toggle('dark', this.dark);
},
portAccessLabel(port, inv) {
if (!inv || !inv.services) return '';
for (const svc of inv.services) {
if (String(svc.port) === String(port) && svc.auth_port) return '🔒 proxy:' + svc.auth_port;
if (String(svc.auth_port) === String(port)) return '🔒 auth proxy';
if (String(svc.port) === String(port)) return '🔓 direct';
}
const portNum = parseInt(port);
if (portNum < 1024) return '⚙️ system';
if (portNum > 10000 && portNum < 20000) return '🔒 internal';
return '';
},
sortPorts(map) {
return Object.fromEntries(Object.entries(map).sort(([a],[b]) => parseInt(a)-parseInt(b)));
},
async fetchHosts() {
try { const r = await fetch('/api/proxmox/stats'); const d = await r.json(); this.hosts = d.hosts || []; }
catch(e) { console.log('host fetch failed', e); }
},
async fetchDocker() {
try { const r = await fetch('/api/docker/stats'); const d = await r.json(); this.dockerContainers = d.containers || []; }
catch(e) { console.log('docker fetch failed', e); }
},
async fetchStatus() {
try { try {
const r = await fetch(STATUS_API); const r = await fetch('/api/status'); const d = await r.json();
const d = await r.json(); if (d.services) { const map = {}; for (const s of d.services) map[s.id] = s.status; this.serviceStatus = map; }
const byId = {}; } catch(e) { console.log('status fetch failed', e); }
for (const s of d.services) { byId[s.id] = s.status; } },
for (const el of document.querySelectorAll("[data-svc]")) {
const sid = el.dataset.svc; init() {
const dot = el.querySelector(".status-dot"); document.documentElement.classList.toggle('dark', this.dark);
if (dot) { // Each service has: external (public domain/ip), lan (192.168.x), ts (tailscale 100.x)
dot.className = "status-dot " + (byId[sid] || "checking"); // Fields left undefined = not applicable
this.publicServices = [
{ id:'gitea', name:'Gitea', desc:'Self-hosted Git — repos, issues, CI', icon:'🦊', external:'https://gitea.ourpad.casa', lan:'192.168.0.223:3000' },
{ id:'pass-vault', name:'Pass Vault', desc:'Web-based secret management', icon:'🔐', external:'https://pass.ourpad.casa', lan:'192.168.0.223:8833' },
{ id:'projects', name:'Project Directory', desc:'This page — infrastructure dashboard', icon:'📂', external:'https://projects.ourpad.casa', lan:'192.168.0.127:8834', ts:'100.89.190.113:8834' },
{ id:'hermes', name:'Hermes Dashboard', desc:'Agent management UI (default profile)', icon:'🤖', external:'https://hermes.ourpad.casa', lan:'192.168.0.127:9119', ts:'100.89.190.113:9119' },
{ id:'coder', name:'Hermes Coder', desc:'Agent dashboard (coder profile)', icon:'💻', external:'https://coder.ourpad.casa', lan:'192.168.0.127:9120', ts:'100.89.190.113:9120' },
{ id:'system-db', name:'System Dashboard', desc:'Real-time CPU/mem/disk/network monitoring', icon:'📊', external:'https://system-dashboard.ourpad.casa', lan:'192.168.0.127:8899', ts:'100.89.190.113:8899' },
{ id:'canteen', name:'Canteen App', desc:'Asset tracker with OCR and GPS (WIP)', icon:'🏗️', external:'https://canteen.ourpad.casa', lan:'192.168.0.127:8901', ts:'100.89.190.113:8901' },
{ id:'canteen-admin',name:'Canteen Admin', desc:'Admin panel — manage assets, users, config', icon:'🛠️', external:'https://admin.canteen.ourpad.casa', lan:'192.168.0.127:8090', ts:'100.89.190.113:8090' },
{ id:'canteen-dev', name:'Canteen (Dev)', desc:'Dev instance — port 8904', icon:'🧪', external:'https://dev.canteen.ourpad.casa', lan:'192.168.0.127:8904', ts:'100.89.190.113:8904' },
{ id:'canteen-admin-dev',name:'Canteen Admin (Dev)',desc:'Dev admin panel — port 8092', icon:'🧪', external:'https://canteen-admin-dev.ourpad.casa',lan:'192.168.0.127:8092', ts:'100.89.190.113:8092' },
{ id:'canteen-seed', name:'Seed Import', desc:'Import seed data — CSV, GPS, OCR (prod :8091)', icon:'🌱', external:'https://canteen-seed.ourpad.casa', lan:'192.168.0.127:8091', ts:'100.89.190.113:8091' },
{ id:'canteen-seed-dev',name:'Seed Import (Dev)',desc:'Dev seed instance — port 8093', icon:'🌱', external:'https://canteen-seed-dev.ourpad.casa',lan:'192.168.0.127:8093', ts:'100.89.190.113:8093' },
{ id:'sonarr', name:'Sonarr', desc:'TV series automation (CT 104)', icon:'📺', external:'https://sonarr.ourpad.casa', lan:'192.168.0.178:8989' },
{ id:'radarr', name:'Radarr', desc:'Movie automation (CT 111)', icon:'🎬', external:'https://radarr.ourpad.casa', lan:'192.168.0.123:7878' },
{ id:'jellyseerr', name:'Jellyseerr', desc:'Media requests — Plex/Overseerr (CT 101)', icon:'🎞️', external:'https://jellyseerr.ourpad.casa', lan:'192.168.0.229:5055' },
{ id:'deluge', name:'Deluge', desc:'Torrent client (CT 110)', icon:'📥', external:'https://deluge.ourpad.casa', lan:'192.168.0.243:8112' },
{ id:'jellyfin', name:'Jellyfin', desc:'Media streaming (ourpad:8096)', icon:'▶️', external:'https://jellyfin.ourpad.casa', lan:'192.168.0.110:8096' },
{ id:'obsidian', name:'Obsidian LiveSync', desc:'CouchDB sync server (CT 100)', icon:'🔄', external:'https://obsidian.ourpad.casa', lan:'192.168.0.223:5984' },
{ id:'inventree', name:'Inventree', desc:'Inventory management (CT 106)', icon:'📦', external:'https://inventory.ourpad.casa', lan:'192.168.0.132:8080' },
{ id:'npmplus', name:'NPMplus', desc:'Reverse proxy admin (ourpad)', icon:'🌐', external:'https://npmplus.ourpad.casa', lan:'192.168.0.110:81' },
{ id:'proxmox', name:'Proxmox Admin', desc:'VE management UI (PVE host)', icon:'🖥️', external:'https://admin.ourpad.casa', lan:'192.168.0.144:8006' },
{ id:'rustdesk', name:'RustDesk', desc:'Remote desktop relay (PVE host)', icon:'🖧', external:'https://rustdesk.ourpad.casa', lan:'192.168.0.144:21116' },
{ id:'status', name:'Network Status', desc:'Live service health monitor', icon:'📡', external:'https://status.ourpad.casa', lan:'192.168.0.127:8898', ts:'100.89.190.113:8898' },
{ id:'dashboard', name:'Heimdall', desc:'Service landing page (CT 113)', icon:'🏠', external:'https://dashboard.ourpad.casa', lan:'192.168.0.213:80', nostatus:true },
{ id:'news', name:'News Brief', desc:'Daily news summary — automated', icon:'📰', external:'https://news.ourpad.casa', lan:'192.168.0.127:8920', ts:'100.89.190.113:8920' },
];
this.internalServices = [
{ id:'prowlarr', name:'Prowlarr', desc:'Indexer aggregator (CT 104)', icon:'🔍', lan:'192.168.0.225:9696' },
{ id:'readarr', name:'Readarr', desc:'Book management (CT 108)', icon:'📚', lan:'192.168.0.193:8787' },
{ id:'configarr', name:'Configarr', desc:'Config sync via API (CT 107)', icon:'⚙️', lan:'192.168.0.238:9091' },
{ id:'unpackerr', name:'Unpackerr', desc:'RAR extraction automation (CT 114)', icon:'📦', lan:'192.168.0.119:5656' },
{ id:'element', name:'Element Synapse', desc:'Matrix chat server (CT 112)', icon:'💬', lan:'192.168.0.220:6167' },
{ id:'portainer', name:'Portainer', desc:'Docker management UI (CT 100)', icon:'🐳', lan:'192.168.0.223:9443' },
{ id:'hermes-webui', name:'Hermes WebUI', desc:'Agent dashboard — alternative UI (CT 102)', icon:'🧠', lan:'192.168.0.127:8787', ts:'100.89.190.113:8787' },
{ id:'deskbrid', name:'Deskbrid', desc:'Desktop automation service', icon:'⌨️' },
{ id:'cf-ddns', name:'Cloudflare DDNS', desc:'Dynamic DNS updater (ourpad CT 105)', icon:'☁️', lan:'192.168.0.110:9090' },
{ id:'tailscale', name:'Tailscale', desc:'Mesh VPN — 3 active nodes', icon:'🔗', ts:'100.89.190.113' },
];
this.fetchHosts(); this.fetchDocker(); this.fetchStatus();
fetch('/api/inventory').then(r=>r.json()).then(d=>this.inv=d).catch(e=>console.log('inv fetch failed', e));
this.pollTimers = [
setInterval(() => this.fetchHosts(), 30000),
setInterval(() => this.fetchDocker(), 30000),
setInterval(() => this.fetchStatus(), 30000),
];
} }
};
} }
} catch(e) { console.log("status check failed"); }
}
update();
setInterval(update, 60000);
})();
</script> </script>
</body> </body>
</html> </html>
Executable
+301
View File
@@ -0,0 +1,301 @@
#!/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"))
HOST = os.environ.get("SERVE_HOST", "127.0.0.1")
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("/api/docker/stats")
async def docker_stats():
"""Get Docker container stats from CT 100 via Proxmox SSH."""
import subprocess
try:
# Run docker stats on CT 100 via proxmox (use raw output to avoid Go template escaping)
result = subprocess.run(
["sshpass", "-p", _PVE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
"root@192.168.0.144", "pct", "exec", "100", "--",
"docker", "stats", "--no-stream"],
capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
return {"error": result.stderr.strip(), "containers": []}
containers = []
for line in result.stdout.strip().split("\n"):
if not line.strip() or line.startswith("CONTAINER"):
continue
# Parse default docker stats output:
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
parts = line.split()
if len(parts) < 8:
continue
# Account for container ID being first column
cid = parts[0]
name = parts[1]
cpu = parts[2].replace("%", "")
mem_usage = parts[3] # e.g., "311.2MiB"
mem_limit = parts[5] # e.g., "2GiB" (parts[4] is "/")
mem_pct = parts[6].replace("%", "")
# NET I/O: parts[7] and parts[9] with "/" at parts[8]
# BLOCK I/O: parts[10] and parts[12] with "/" at parts[11]
# PIDS: last element (could be at different positions depending on name length)
# More reliable: find from the right
pids = parts[-1]
# Block I/O is tricky because of spaces in column names;
# use a simpler approach: rest of line after NET I/O position
# Actually, docker stats columns are fixed-width, let's just grab what we can
net_in = parts[7] if len(parts) > 7 else "0"
net_out = parts[9] if len(parts) > 9 else "0"
blk_in = parts[10] if len(parts) > 10 else "0"
blk_out = parts[12] if len(parts) > 12 else "0"
containers.append({
"name": name,
"cpu": cpu,
"mem_pct": mem_pct,
"mem_used": mem_usage,
"mem_limit": mem_limit,
"net_in": net_in,
"net_out": net_out,
"blk_in": blk_in,
"blk_out": blk_out,
"pids": pids,
})
return {"containers": containers, "host": "CT 100 (docker)", "count": len(containers)}
except Exception as e:
return {"error": str(e)[:200], "containers": []}
@app.get("/api/windows/stats")
async def windows_stats():
"""Get live stats from Windows Gaming PC via PowerShell over SSH."""
import subprocess, base64, json as _json
# PowerShell script: backslash counter paths need single \, Python raw string preserves them
ps_script = r'''
$cpu=(Get-Counter '\Processor(_Total)\% Processor Time').CounterSamples[0].CookedValue
$os=Get-CimInstance Win32_OperatingSystem
$disk=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$uptime=(Get-Date)-$os.LastBootUpTime
@{
'cpu'=[math]::Round($cpu,1)
'mem_total_gb'=[math]::Round($os.TotalVisibleMemorySize/1MB,1)
'mem_free_gb'=[math]::Round($os.FreePhysicalMemory/1MB,1)
'disk_c_total_gb'=[math]::Round($disk.Size/1GB,1)
'disk_c_free_gb'=[math]::Round($disk.FreeSpace/1GB,1)
'uptime_days'=[math]::Round($uptime.TotalDays,1)
} | ConvertTo-Json
'''.strip()
try:
encoded = base64.b64encode(ps_script.encode('utf-16le')).decode()
result = subprocess.run(
["ssh", "gamingpc",
"powershell", "-NoProfile", "-EncodedCommand", encoded],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return {"error": result.stderr.strip()[:300], "online": False}
# Filter CLIXML noise from stdout
clean = "\n".join(l for l in result.stdout.split("\n")
if l.strip() and not l.startswith("#<") and not l.startswith("<"))
data = _json.loads(clean)
data["online"] = True
data["mem_used_gb"] = round(data["mem_total_gb"] - data["mem_free_gb"], 1)
data["disk_c_used_gb"] = round(data["disk_c_total_gb"] - data["disk_c_free_gb"], 1)
return data
except Exception as e:
return {"error": str(e)[:300], "online": False}
@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>\n", 404)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=HOST, port=PORT)