507 lines
17 KiB
Plaintext
507 lines
17 KiB
Plaintext
---
|
||
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>
|