RC: (upload) astro initial structure

This commit is contained in:
Raul Costa
2026-04-01 00:19:49 +01:00
commit 8c11192e7b
29 changed files with 8561 additions and 0 deletions

506
src/pages/homelab.astro Normal file
View File

@@ -0,0 +1,506 @@
---
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>