Files
slashroot-cc/src/pages/homelab.astro
2026-04-01 00:19:49 +01:00

507 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Homelab" description="Live status and architecture of my homelab — h0melab.uk">
<div class="homelab-page">
<!-- Hero -->
<div class="page-hero container">
<div class="accent-line"></div>
<h1>Homelab <span class="text-accent">Status</span></h1>
<p class="text-muted font-mono" style="font-size:0.88rem;margin-top:0.5rem">
h0melab.uk · Edinburgh ·
<span id="uptime-badge" class="text-accent">fetching uptime…</span>
</p>
</div>
<!-- Architecture diagram -->
<section class="section container">
<h2 class="section-title">Architecture</h2>
<div class="arch-diagram card">
<div class="arch-grid" id="arch-grid">
<!-- Tier: Internet -->
<div class="arch-tier">
<div class="tier-label font-mono">internet</div>
<div class="arch-node node-external">
<span class="node-icon">☁</span>
<span class="node-name">Cloudflare</span>
<span class="node-detail font-mono">rcosta.uk · DNS · Email routing</span>
</div>
</div>
<div class="arch-arrow">↓</div>
<!-- Tier: Edge -->
<div class="arch-tier">
<div class="tier-label font-mono">edge</div>
<div class="arch-node node-router">
<span class="node-icon">⬡</span>
<span class="node-name">OPNsense</span>
<span class="node-detail font-mono">26.1 · GeoIP · WireGuard · Unbound</span>
</div>
</div>
<div class="arch-arrow">↓</div>
<!-- Tier: Core -->
<div class="arch-tier">
<div class="tier-label font-mono">core</div>
<div class="arch-row">
<div class="arch-node node-compute">
<span class="node-icon">▣</span>
<span class="node-name">Proxmox VE</span>
<span class="node-detail font-mono">2-node cluster · VLAN trunk</span>
</div>
<div class="arch-node node-storage">
<span class="node-icon">◈</span>
<span class="node-name">ZFS · zfs-oporto</span>
<span class="node-detail font-mono">RAIDZ2 · 4×HDD · NFS exports</span>
</div>
</div>
</div>
<div class="arch-arrow">↓</div>
<!-- Tier: K8s -->
<div class="arch-tier">
<div class="tier-label font-mono">kubernetes</div>
<div class="arch-row">
<div class="arch-node node-k8s">
<span class="node-icon">◎</span>
<span class="node-name">Control Plane</span>
<span class="node-detail font-mono">kubeadm · Cilium CNI</span>
</div>
<div class="arch-node node-k8s">
<span class="node-icon">◎</span>
<span class="node-name">Worker ×2</span>
<span class="node-detail font-mono">Longhorn · Flux GitOps</span>
</div>
<div class="arch-node node-k8s">
<span class="node-icon">◎</span>
<span class="node-name">Ingress</span>
<span class="node-detail font-mono">Traefik · Authelia · cert-manager</span>
</div>
</div>
</div>
<div class="arch-arrow">↓</div>
<!-- Tier: Services -->
<div class="arch-tier">
<div class="tier-label font-mono">services</div>
<div class="arch-services">
{[
['Grafana', 'monitoring'],
['VictoriaMetrics', 'metrics'],
['Netdata', 'telemetry'],
['Paperless', 'documents'],
['Immich', 'photos'],
['Linkwarden', 'bookmarks'],
['Jellyfin', 'media'],
['Gitea', 'git'],
['Authelia', 'SSO'],
['Homepage', 'dashboard'],
['ntfy', 'notifications'],
['Stalwart', 'mail (paused)'],
].map(([name, role]) => (
<div class="service-chip">
<span class="chip-name">{name}</span>
<span class="chip-role font-mono">{role}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<hr class="glow-divider container" />
<!-- Live metrics -->
<section class="section container" id="metrics">
<h2 class="section-title">Live Metrics</h2>
<p class="text-muted font-mono" style="font-size:0.8rem;margin-bottom:2rem">
Auto-refreshes every 30s ·
<span id="metrics-ts"></span>
</p>
<!-- Summary row -->
<div class="metrics-summary" id="metrics-summary">
<!-- JS populated -->
</div>
<!-- Per-node detail table -->
<div class="node-table-wrapper card" style="margin-top:2rem">
<table class="node-table" id="node-table">
<thead>
<tr>
<th class="font-mono">HOST</th>
<th class="font-mono">ROLE</th>
<th class="font-mono">CPU</th>
<th class="font-mono">RAM</th>
<th class="font-mono">LOAD</th>
<th class="font-mono">STATUS</th>
</tr>
</thead>
<tbody id="node-tbody">
<!-- JS populated -->
</tbody>
</table>
</div>
<!-- Network I/O -->
<div style="margin-top:2rem">
<h3 class="section-title" style="font-size:1.1rem;margin-bottom:1rem">
Network I/O <span class="text-muted font-mono" style="font-size:0.75rem">(last 5 min average)</span>
</h3>
<div class="network-grid" id="network-grid">
<!-- JS populated -->
</div>
</div>
</section>
<hr class="glow-divider container" />
<!-- Domain / service map -->
<section class="section container">
<h2 class="section-title">Service Map</h2>
<p class="text-muted font-mono" style="font-size:0.8rem;margin-bottom:2rem">
All services live under <span class="text-accent">*.int.h0melab.uk</span> · Authelia SSO on everything external-facing
</p>
<div class="service-map-grid">
{[
{
domain: 'auth.h0melab.uk',
name: 'Authelia',
desc: 'SSO / 2FA gateway',
public: true,
},
{
domain: 'grafana.int.h0melab.uk',
name: 'Grafana',
desc: 'Dashboards · VictoriaMetrics source',
public: false,
},
{
domain: 'netdata.int.h0melab.uk',
name: 'Netdata',
desc: 'Per-node real-time telemetry',
public: false,
},
{
domain: 'immich.int.h0melab.uk',
name: 'Immich',
desc: 'Photo backup & browse',
public: false,
},
{
domain: 'paperless.int.h0melab.uk',
name: 'Paperless-ngx',
desc: 'Document archive · OIDC auth',
public: false,
},
{
domain: 'chatai.h0melab.uk',
name: 'Open WebUI',
desc: 'Local LLM · Ollama backend',
public: false,
},
{
domain: 'git.int.h0melab.uk',
name: 'Gitea',
desc: 'Internal Git repos',
public: false,
},
{
domain: 'home.h0melab.uk',
name: 'Homepage',
desc: 'Dashboard · FC Porto widget',
public: false,
},
].map(svc => (
<div class="card svc-card">
<div class="svc-header">
<span class="svc-name font-display">{svc.name}</span>
{svc.public
? <span class="tag" style="color:#FFB800;border-color:#FFB800;background:rgba(255,184,0,0.08)">public</span>
: <span class="tag">internal</span>
}
</div>
<code class="svc-domain font-mono">{svc.domain}</code>
<p class="svc-desc text-muted">{svc.desc}</p>
</div>
))}
</div>
</section>
</div>
</BaseLayout>
<script>
// ── Demo / live data (same pattern as HomelabStatus component) ──
const USE_DEMO = true;
const DEMO = {
nodes: [
{ name: 'pve-01', role: 'Proxmox', cpu: 41, ram: 72, load: '3.2', status: 'online', rx: '1.2 Mb/s', tx: '0.8 Mb/s' },
{ name: 'pve-02', role: 'Proxmox', cpu: 28, ram: 65, load: '2.1', status: 'online', rx: '0.9 Mb/s', tx: '0.4 Mb/s' },
{ name: 'k8s-control', role: 'K8s control', cpu: 12, ram: 55, load: '0.9', status: 'online', rx: '0.3 Mb/s', tx: '0.2 Mb/s' },
{ name: 'k8s-worker1', role: 'K8s worker', cpu: 28, ram: 61, load: '2.4', status: 'online', rx: '2.1 Mb/s', tx: '1.8 Mb/s' },
{ name: 'k8s-worker2', role: 'K8s worker', cpu: 19, ram: 58, load: '1.7', status: 'online', rx: '1.4 Mb/s', tx: '1.1 Mb/s' },
{ name: 'zfs-oporto', role: 'NAS / ZFS', cpu: 5, ram: 22, load: '0.3', status: 'online', rx: '3.2 Mb/s', tx: '4.1 Mb/s' },
{ name: 'opnsense', role: 'Router', cpu: 8, ram: 31, load: '0.6', status: 'online', rx: '8.4 Mb/s', tx: '6.2 Mb/s' },
{ name: 'stalwart-mail',role: 'Mail (paused)', cpu: 0, ram: 0, load: '', status: 'offline', rx: '', tx: '' },
],
summary: { cpu: 20, ram: 58, storage: 61, network: '2.4 Gb/s' },
uptime: '47d 12h 34m',
};
function bar(pct) {
if (typeof pct !== 'number') return '<span class="text-muted font-mono" style="font-size:0.75rem"></span>';
const c = pct > 85 ? '#FF4444' : pct > 70 ? '#FFB800' : '#00D2BE';
return `<div style="display:flex;align-items:center;gap:0.5rem">
<div style="flex:1;height:4px;background:var(--bg-surface);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${c};transition:width 0.8s ease"></div>
</div>
<span class="font-mono" style="font-size:0.72rem;color:var(--text-muted);min-width:2.5rem">${pct}%</span>
</div>`;
}
function statusBadge(s) {
const c = s === 'online' ? '#00D2BE' : '#FF4444';
return `<span style="display:inline-flex;align-items:center;gap:0.35rem;font-family:var(--font-mono);font-size:0.72rem;color:${c}">
<span style="width:6px;height:6px;border-radius:50%;background:${c};box-shadow:0 0 6px ${c}"></span>${s}
</span>`;
}
async function render() {
const data = USE_DEMO ? DEMO : DEMO; // swap with real fetch
// Uptime
const ut = document.getElementById('uptime-badge');
if (ut) ut.textContent = `uptime ${data.uptime}`;
// Timestamp
const ts = document.getElementById('metrics-ts');
if (ts) ts.textContent = 'updated ' + new Date().toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
// Summary cards
const summary = document.getElementById('metrics-summary');
if (summary) {
summary.innerHTML = [
{ label: 'Avg CPU', value: `${data.summary.cpu}%`, pct: data.summary.cpu },
{ label: 'Avg RAM', value: `${data.summary.ram}%`, pct: data.summary.ram },
{ label: 'ZFS Used', value: `${data.summary.storage}%`, pct: data.summary.storage },
{ label: 'Network', value: data.summary.network, pct: null },
].map(m => `
<div class="card summary-card">
<div class="summary-label font-mono">${m.label}</div>
<div class="summary-value font-display">${m.value}</div>
${m.pct !== null ? `<div style="margin-top:0.5rem">${bar(m.pct)}</div>` : ''}
</div>
`).join('');
}
// Node table
const tbody = document.getElementById('node-tbody');
if (tbody) {
tbody.innerHTML = data.nodes.map(n => `
<tr>
<td class="font-mono" style="color:var(--text-primary)">${n.name}</td>
<td class="font-mono text-muted" style="font-size:0.78rem">${n.role}</td>
<td style="min-width:120px">${bar(n.status === 'offline' ? null : n.cpu)}</td>
<td style="min-width:120px">${bar(n.status === 'offline' ? null : n.ram)}</td>
<td class="font-mono text-muted" style="font-size:0.78rem">${n.load}</td>
<td>${statusBadge(n.status)}</td>
</tr>
`).join('');
}
// Network I/O
const netGrid = document.getElementById('network-grid');
if (netGrid) {
netGrid.innerHTML = data.nodes
.filter(n => n.status === 'online')
.map(n => `
<div class="card net-card">
<div class="font-mono" style="font-size:0.75rem;color:var(--petronas-teal);margin-bottom:0.5rem">${n.name}</div>
<div class="net-row">
<span class="text-muted font-mono" style="font-size:0.7rem">↓ RX</span>
<span class="font-mono" style="font-size:0.8rem">${n.rx}</span>
</div>
<div class="net-row">
<span class="text-muted font-mono" style="font-size:0.7rem">↑ TX</span>
<span class="font-mono" style="font-size:0.8rem">${n.tx}</span>
</div>
</div>
`).join('');
}
}
render();
setInterval(render, 30_000);
</script>
<style>
.homelab-page { padding-top: 8rem; }
.page-hero { margin-bottom: 4rem; }
.section-title {
font-family: var(--font-display);
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
/* Architecture diagram */
.arch-diagram { padding: 2rem; }
.arch-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.arch-tier {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 0;
border-bottom: 1px solid var(--border-subtle);
}
.arch-tier:last-child { border-bottom: none; }
.tier-label {
font-size: 0.65rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.25rem;
align-self: flex-start;
}
.arch-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.arch-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.85rem 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
min-width: 180px;
text-align: center;
transition: border-color var(--transition-fast);
}
.arch-node:hover { border-color: var(--border-glow); }
.node-icon { font-size: 1.2rem; color: var(--petronas-teal); }
.node-name { font-family: var(--font-display); font-size: 0.95rem; font-weight: 600; }
.node-detail { font-size: 0.68rem; color: var(--text-muted); margin-top: 0.1rem; }
.node-router .node-icon { color: #FFB800; }
.node-external .node-icon { color: var(--text-muted); }
.node-storage .node-icon { color: #7B9EFF; }
.arch-arrow {
font-size: 1.2rem;
color: var(--petronas-teal);
opacity: 0.4;
padding: 0.1rem 0;
}
.arch-services {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
}
.service-chip {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-surface);
transition: border-color var(--transition-fast);
}
.service-chip:hover { border-color: var(--border-glow); }
.chip-name { font-family: var(--font-display); font-size: 0.82rem; font-weight: 600; }
.chip-role { font-size: 0.62rem; color: var(--petronas-teal); margin-top: 0.1rem; }
/* Metrics */
.metrics-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.summary-card { padding: 1.25rem; }
.summary-label { font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.4rem; }
.summary-value { font-size: 1.8rem; font-weight: 700; color: var(--petronas-teal); line-height: 1; }
/* Node table */
.node-table-wrapper { overflow-x: auto; padding: 0; }
.node-table { width: 100%; border-collapse: collapse; }
.node-table th {
padding: 0.75rem 1.25rem;
text-align: left;
font-size: 0.65rem;
letter-spacing: 0.12em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
}
.node-table td {
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.node-table tr:last-child td { border-bottom: none; }
.node-table tr:hover td { background: var(--bg-card-hover); }
/* Network */
.network-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.net-card { padding: 1rem; }
.net-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
/* Service map */
.service-map-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.svc-card { padding: 1.25rem; }
.svc-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.svc-name { font-size: 1rem; }
.svc-domain { font-size: 0.72rem; color: var(--petronas-teal); display: block; margin-bottom: 0.5rem; }
.svc-desc { font-size: 0.8rem; }
@media (max-width: 768px) {
.metrics-summary { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.metrics-summary { grid-template-columns: 1fr; }
}
</style>