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

View File

@@ -0,0 +1,53 @@
name: Build and Update Flux
on:
push:
branches:
- main
paths-ignore:
# CRITICAL: Do not trigger this action if we are just updating the Helm chart,
# otherwise it will create an infinite loop!
- "helm/**"
env:
REGISTRY: git.h0melab.uk
IMAGE_NAME: git.h0melab.uk/rgcosta/slashroot-cc
jobs:
build-push-update:
runs-on: gitea-runner-docker
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Log in to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:latest
- name: Update Helm values.yaml for Flux
run: |
# 1. Update the image tag in your local Helm chart using sed
sed -i "s/tag: .*/tag: ${{ gitea.sha }}/g" charts/slashroot/values.yaml
# 2. Configure the Gitea Bot to commit the change
git config user.name "Gitea Actions Bot"
git config user.email "actions@gitea.local"
# 3. Commit and push the updated values.yaml back to the main branch
git add charts/slashroot/values.yaml
git commit -m "chore: update slashroot image tag to ${{ gitea.sha }} [skip ci]"
git push

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.vscode/

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Stage 1: Build the Astro project
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
# Copy the Astro output from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80 for your Helm chart / Traefik to pick up
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# rack::log — Sysadmin Blog
AMG Petronas green themed personal blog built with Astro.
## Stack
- **Astro 4** — static site framework
- **MDX** — blog posts in Markdown with component support
- **Content Collections** — typed blog frontmatter
- **GSAP** (optional) — parallax scroll
- Zero CSS frameworks — all custom, CSS variables
## Quick Start (WSL / Debian)
```bash
cd sysadmin-blog
npm install
npm run dev
# → http://localhost:4321
```
## File Structure
```
src/
├── components/
│ ├── ParallaxHero.astro ← rack corridor parallax landing hero
│ ├── RecentPosts.astro ← last 3 blog posts (auto-fetched)
│ └── HomelabStatus.astro ← live metrics widget (demo + real API)
├── content/
│ ├── config.ts ← blog collection schema
│ └── blog/
│ ├── day-001-*.md ← Day 1 entry
│ ├── day-002-*.md ← Day 2 entry
│ └── day-003-*.md ← Day 3 entry
├── layouts/
│ ├── BaseLayout.astro ← nav + footer wrapper
│ └── PostLayout.astro ← blog post with TOC sidebar
├── pages/
│ ├── index.astro ← landing (hero + posts + homelab)
│ ├── blog/
│ │ ├── index.astro ← all posts listing
│ │ └── [slug].astro ← dynamic post page
│ ├── homelab.astro ← architecture diagram + live metrics
│ ├── hardware.astro ← full hardware inventory
│ └── 404.astro
└── styles/
└── global.css ← all CSS variables, typography, base
```
## Writing a New Blog Post
Create `src/content/blog/day-NNN-your-title.md`:
```markdown
---
title: "Day 4 — Setting Up the Monitoring Stack"
description: "Netdata → VictoriaMetrics → Grafana"
pubDate: 2024-11-22
day: 4
tags: ["monitoring", "grafana", "victoriametrics", "netdata"]
---
Your content here...
```
The `day` field is optional — use it for the numbered series entries.
## Connecting Live Homelab Metrics
In `src/components/HomelabStatus.astro` and `src/pages/homelab.astro`:
1. Set `USE_DEMO = false`
2. Set `NETDATA_BASE` to your Netdata URL (e.g. `http://netdata.int.h0melab.uk`)
3. The fetch calls use the Netdata REST API v1 — adapt to your endpoint structure
For a custom FastAPI endpoint (like your FC Porto fixture scraper pattern):
```typescript
// Example: GET /api/homelab/status
const data = await fetch('https://api.h0melab.uk/homelab/status').then(r => r.json());
```
## Colour Variables
All theme colours live in `src/styles/global.css`:
```css
--petronas-teal: #00D2BE
--petronas-green: #00A19C
--petronas-dark: #007A76
--bg-void: #050A0A
```
## Deployment
```bash
npm run build # outputs to dist/
# Deploy dist/ to any static host: Cloudflare Pages, Netlify, Vercel, nginx
```
For Cloudflare Pages (recommended for rcosta.uk):
- Build command: `npm run build`
- Output directory: `dist`

8
astro.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://slashroot.cc',
integrations: [mdx(), sitemap()],
});

5546
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "sysadmin-blog",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^4.15.0",
"@astrojs/mdx": "^3.1.0",
"@astrojs/sitemap": "^3.1.0",
"gsap": "^3.12.5"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

16
public/favicon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#050A0A"/>
<rect x="5" y="4" width="22" height="24" rx="2" fill="none" stroke="#00D2BE" stroke-width="1.5"/>
<!-- rack units -->
<rect x="7" y="7" width="18" height="3" rx="1" fill="#0D1E1C"/>
<rect x="7" y="7" width="3" height="3" rx="1" fill="#00D2BE"/>
<rect x="7" y="12" width="18" height="3" rx="1" fill="#0D1E1C"/>
<rect x="7" y="12" width="3" height="3" rx="1" fill="#007A76"/>
<rect x="7" y="17" width="18" height="3" rx="1" fill="#0D1E1C"/>
<rect x="7" y="17" width="3" height="3" rx="1" fill="#00D2BE" opacity="0.6"/>
<rect x="7" y="22" width="18" height="3" rx="1" fill="#0D1E1C"/>
<rect x="7" y="22" width="3" height="3" rx="1" fill="#004F4D"/>
<!-- LED dot -->
<circle cx="22" cy="8.5" r="1.2" fill="#00D2BE" opacity="0.9"/>
<circle cx="22" cy="13.5" r="1.2" fill="#00D2BE" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

View File

@@ -0,0 +1,338 @@
---
// HomelabStatus.astro
// Set USE_DEMO=false and API_URL to your actual endpoint once deployed.
---
<section class="section homelab-status" id="homelab-status">
<div class="container">
<div class="hl-header">
<div>
<div class="accent-line"></div>
<h2>Homelab <span class="text-accent">Status</span></h2>
<p class="font-mono text-muted" style="font-size:0.82rem;margin-top:0.4rem">
Proxmox · pve-node-01 ·
<span id="hl-ts" class="text-accent" style="font-size:0.75rem">connecting...</span>
</p>
</div>
<a href="/homelab" class="btn btn-primary">Full overview →</a>
</div>
<div class="hl-grid" id="hl-grid">
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
</div>
<div class="hl-detail card" id="hl-detail" style="margin-top:1.5rem;display:none">
<div class="hl-detail-grid">
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">CPU history (1 min)</span>
<div class="spark-bars" id="bars-cpu"></div>
</div>
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">Load history (1 min)</span>
<div class="spark-bars" id="bars-load"></div>
</div>
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">Network RX/TX (1 min)</span>
<div class="spark-bars" id="bars-net"></div>
</div>
</div>
</div>
</div>
</section>
<script is:inline>
// ── CONFIG ────────────────────────────────────────────────────────────
const API_URL = 'http://pve.int:8080/api/homelab'; // Or use raw IP
const SPARK_H = 48; // Pixel height of .spark-bars
// ── FALLBACK ROLLING BUFFERS ──────────────────────────────────────────
let fallbackCpu = Array(60).fill(0);
let fallbackLoad = Array(60).fill(0);
let fallbackRx = Array(60).fill(0);
let fallbackTx = Array(60).fill(0);
// ── DATA PARSER ───────────────────────────────────────────────────────
function parseData(d, isLive, timestamp = null) {
const cCpu = Math.round(d.cpu || 0);
const cRam = Math.round(d.ram?.percent ?? d.ram ?? 0);
const cLoad = d.load_avg ? d.load_avg[0] : 0;
const cRx = d.rx_mb || 0;
const cTx = d.tx_mb || 0;
function roll(buf, hist, val) {
if (hist && hist.length > 0) return hist;
buf.shift(); buf.push(val);
return [...buf];
}
return {
isLive,
timestamp,
cpu: cCpu,
ram_used: cRam,
root_disk: Math.round(d.root_disk_pct || 0),
rx_mb: cRx,
tx_mb: cTx,
cpu_hist: roll(fallbackCpu, d.cpu_hist, cCpu),
load_hist: roll(fallbackLoad, d.load_hist, cLoad),
rx_hist: roll(fallbackRx, d.rx_hist, cRx),
tx_hist: roll(fallbackTx, d.tx_hist, cTx),
};
}
// ── CACHED FETCH LOGIC ────────────────────────────────────────────────
async function fetchLive() {
try {
// 1. Try to fetch the live data
const res = await fetch(API_URL, { signal: AbortSignal.timeout(4000) });
const d = await res.json();
if (d.error) throw new Error(d.error);
// 2. If successful, save it to the browser's Local Storage!
localStorage.setItem('homelab_cache', JSON.stringify(d));
localStorage.setItem('homelab_ts', Date.now());
return parseData(d, true);
} catch (e) {
console.warn('API fetch failed, attempting to load from cache...', e);
// 3. If it fails, pull the last known good data from memory
const cachedData = localStorage.getItem('homelab_cache');
const cachedTime = localStorage.getItem('homelab_ts');
if (cachedData) {
return parseData(JSON.parse(cachedData), false, parseInt(cachedTime));
} else {
throw new Error("No cache available and API is offline.");
}
}
}
// ── RENDER ────────────────────────────────────────────────────────────
function bar(pct) {
const c = pct > 85 ? '#FF4444' : pct > 70 ? '#FFB800' : '#00D2BE';
return `<div class="hl-bar-track"><div class="hl-bar-fill" style="width:${pct}%;background:${c}"></div></div>`;
}
function renderCards(d) {
const cards = [
{ label: 'CPU', value: `${d.cpu}%`, sub: 'overall usage', pct: d.cpu, icon: '⬡' },
{ label: 'RAM', value: `${d.ram_used}%`, sub: 'used of total', pct: d.ram_used, icon: '▣' },
{ label: 'Disk', value: `${d.root_disk}%`, sub: '/ partition usage', pct: d.root_disk, icon: '⛁' },
{ label: 'Network', value: `↓${d.rx_mb} ↑${d.tx_mb}`, sub: 'Mbps rx / tx', pct: null, icon: '⇅' },
];
const grid = document.getElementById('hl-grid');
if (grid) {
grid.innerHTML = cards.map(c => `
<div class="hl-card card ${d.isLive ? '' : 'offline-card'}">
<div class="hl-card-top">
<span class="hl-icon">${c.icon}</span>
<span class="hl-label font-mono">${c.label}</span>
</div>
<div class="hl-value font-display">${c.value}</div>
<div class="hl-sub text-muted">${c.sub}</div>
${c.pct !== null ? bar(c.pct) : ''}
</div>
`).join('');
}
}
function renderSparks(d) {
document.getElementById('hl-detail').style.display = '';
renderSparkBars('bars-cpu', d.cpu_hist, '#00D2BE');
renderSparkBars('bars-load', d.load_hist, '#00A19C');
renderSparkBars('bars-net', d.rx_hist, '#007A76', d.tx_hist);
}
// BULLETPROOF SVG RENDERER
function renderSparkBars(id, series, color, series2) {
const el = document.getElementById(id);
if (!el || !series.length) return;
const W = 240;
const H = SPARK_H;
const max = Math.max(...series, ...(series2 || []), 0.05);
let rects = '';
const step = W / series.length;
const barW = series2 ? 1.5 : 3;
series.forEach((v, i) => {
const x = i * step;
const h1 = Math.max(1, (v / max) * H);
const y1 = H - h1;
if (series2) {
const h2 = Math.max(1, ((series2[i] || 0) / max) * H);
const y2 = H - h2;
rects += `<rect x="${x}" y="${y1}" width="${barW}" height="${h1}" fill="${color}" opacity="0.9"/>`;
rects += `<rect x="${x + barW}" y="${y2}" width="${barW}" height="${h2}" fill="#005F5D" opacity="0.7"/>`;
} else {
rects += `<rect x="${x}" y="${y1}" width="${barW}" height="${h1}" fill="${color}"/>`;
}
});
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" style="width:100%; height:${H}px; display:block; ${series2 ? '' : 'opacity: 0.8;'}">${rects}</svg>`;
}
async function render() {
const ts = document.getElementById('hl-ts');
try {
const data = await fetchLive();
renderCards(data);
renderSparks(data);
// Update the timestamp based on live/offline status
if (data.isLive) {
ts.style.color = 'var(--petronas-teal)';
ts.textContent = 'updated ' + new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
ts.style.color = '#FFB800'; // Warning Orange
const oldTime = new Date(data.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
ts.textContent = `offline · last seen ${oldTime}`;
}
} catch (e) {
// Only happens if the API is dead AND there is no cache
if (ts) {
ts.style.color = '#FF4444'; // Error Red
ts.textContent = 'API unreachable · no cache found';
}
}
}
render();
// refresh every 10 sec
setInterval(render, 10000);
</script>
<style>
.homelab-status { padding-top: 4rem; }
.hl-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 2.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.hl-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.25rem;
}
.hl-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 130px;
transition: opacity 0.3s ease;
}
/* Make cards look slightly faded when offline */
.offline-card {
opacity: 0.6;
filter: grayscale(40%);
}
.hl-card.skeleton {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
animation: skeleton-pulse 1.8s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.hl-card-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hl-icon {
font-size: 1.1rem;
color: var(--petronas-teal);
opacity: 0.8;
}
.hl-label {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.hl-value {
font-size: 2.2rem;
font-weight: 700;
color: var(--petronas-teal);
line-height: 1;
letter-spacing: -0.01em;
}
.hl-sub {
font-size: 0.72rem;
color: var(--text-secondary);
margin-bottom: 0.4rem;
}
.hl-bar-track {
height: 3px;
background: var(--bg-surface);
border-radius: 2px;
overflow: hidden;
margin-top: auto;
}
.hl-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.9s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Sparklines */
.hl-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
padding: 1.25rem;
}
.hl-spark-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hl-spark-label {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.spark-bars {
width: 100%;
height: 48px;
}
@media (max-width: 900px) {
.hl-grid { grid-template-columns: 1fr 1fr; }
.hl-detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.hl-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,334 @@
---
// ParallaxHero.astro — Osmo-style multi-layer GSAP parallax, Petronas theme
---
<div class="parallax">
<section class="parallax__header">
<div class="parallax__visuals">
<div data-parallax-layers class="parallax__layers">
<!-- LAYER 1 — bg photo (slowest, most travel) -->
<img
src="/image_zb1c2e65feww76x.png"
loading="eager"
data-parallax-layer="1"
alt=""
class="parallax__layer-img"
/>
<!-- LAYER 2 — teal colour grade (follows bg) -->
<div data-parallax-layer="2" class="parallax__layer-tint"></div>
<!-- LAYER 3 — title (mid speed) -->
<div data-parallax-layer="3" class="parallax__layer-title">
<h1 class="parallax__title">
<span class="parallax__title-accent">/</span>slashroot
</h1>
<!-- <div class="parallax__eyebrow font-mono">
<span style="color:var(--petronas-teal)">$</span> cat /var/log/adventures.log
</div> -->
<div class="parallax__cta">
<a href="/blog" class="btn btn-primary">Read the logs</a>
</div>
</div>
<!-- LAYER 4 — figure foreground PNG (barely moves = pops forward) -->
<div data-parallax-layer="4" class="parallax__layer-figure" aria-hidden="true">
<img
src="/image_zb1c25zb1c25zb1c.png"
alt=""
class="parallax__figure-img"
/>
</div>
</div>
<!-- Bottom fade -->
<div class="parallax__fade"></div>
<!-- Scroll hint inside header for absolute positioning -->
<div class="parallax__scroll-hint" id="scroll-hint" aria-hidden="true">
<span class="font-mono">scroll</span>
<div class="scroll-line"></div>
</div>
</div>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" is:inline></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" is:inline></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.1.13/dist/lenis.min.js" is:inline></script>
<script is:inline>
if (history.scrollRestoration) {
history.scrollRestoration = "manual";
}
document.addEventListener("DOMContentLoaded", () => {
gsap.registerPlugin(ScrollTrigger);
document.querySelectorAll('[data-parallax-layers]').forEach((trigger) => {
const header = trigger.closest('.parallax__header');
const tl = gsap.timeline({
scrollTrigger: {
trigger: header,
start: "top top",
end: "bottom top",
scrub: 0,
invalidateOnRefresh: true
}
});
// tl.to('[data-parallax-layer="1"]', { yPercent: 40, ease: "none" }, 0)
tl.fromTo('[data-parallax-layer="1"]',
{ yPercent: 0 }, // FORCE the start position
{ yPercent: 40, ease: "none" }, // The end position
0 // Keep your 0 position parameter
)
.to('[data-parallax-layer="2"]', { yPercent: 40, ease: "none" }, 0)
.to('[data-parallax-layer="3"]', { yPercent: 20, ease: "none" }, 0)
.to('[data-parallax-layer="4"]', { yPercent: 6, ease: "none" }, 0);
});
// Lenis smooth scroll
const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
// Fade scroll hint
const hint = document.getElementById('scroll-hint');
if (hint) {
ScrollTrigger.create({
start: 60,
onEnter: () => gsap.to(hint, { opacity: 0, duration: 0.4 }),
onLeaveBack: () => gsap.to(hint, { opacity: 1, duration: 0.4 }),
});
}
// Entrance animation
const intro = gsap.timeline({ defaults: { ease: "power2.out" } });
intro
.from('.parallax__eyebrow', { opacity: 0, y: 20, duration: 0.8 }, 0.3)
.from('.parallax__title', { opacity: 0, y: 32, duration: 1.0 }, 0.5)
.from('.parallax__subtitle', { opacity: 0, y: 16, duration: 0.8 }, 0.72)
.from('.parallax__cta', { opacity: 0, y: 12, duration: 0.7 }, 0.92)
.from('.parallax__figure-img', { opacity: 0, y: 24, duration: 1.0 }, 0.25);
window.addEventListener('load', () => {
ScrollTrigger.refresh();
});
});
</script>
<style>
.parallax {
position: relative;
}
/* Osmo natural height — no 200vh hack */
.parallax__header {
min-height: 100svh;
position: relative;
z-index: 2;
}
/* Osmo: absolute + 120% height, no sticky */
.parallax__visuals {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 110%;
overflow: hidden;
}
.parallax__layers {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Osmo magic offset — pulled up so it has room to scroll down */
.parallax__layer-img {
position: absolute;
top: -20.5%;
left: 0;
width: 100%;
height: 117.5%;
object-fit: cover;
object-position: center 30%;
display: block;
filter: saturate(0.25) brightness(0.45);
pointer-events: none;
will-change: transform;
}
/* Layer 2 — teal tint, same geometry as bg */
.parallax__layer-tint {
position: absolute;
top: -17.5%;
left: 0;
width: 100%;
height: 117.5%;
background:
radial-gradient(ellipse 65% 50% at 50% 60%, rgba(0,210,190,0.13) 0%, transparent 65%),
linear-gradient(to bottom,
rgba(5,10,10,0.65) 0%,
transparent 20%,
transparent 65%,
rgba(5,10,10,0.85) 100%
),
linear-gradient(to right,
rgba(5,10,10,0.5) 0%,
transparent 25%,
transparent 75%,
rgba(5,10,10,0.5) 100%
);
will-change: transform;
}
/* Layer 3 — title: same top offset as bg, centred vertically */
.parallax__layer-title {
position: absolute;
top: -20.5%;
left: 0;
width: 100%;
height: 117.5%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 1.5rem;
padding-bottom: 12vh;
text-align: center;
z-index: 1;
will-change: transform;
}
.parallax__eyebrow {
font-size: clamp(0.68rem, 1.4vw, 0.88rem);
color: var(--text-muted);
letter-spacing: 0.12em;
margin-bottom: 1rem;
}
.parallax__title {
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(4rem, 13vw, 11rem);
letter-spacing: -0.02em;
line-height: 0.92;
color: var(--text-primary);
text-shadow:
0 0 120px rgba(0,210,190,0.2),
0 4px 60px rgba(0,0,0,0.95);
margin-bottom: 1.25rem;
}
.parallax__title-accent { color: var(--petronas-teal); }
.parallax__subtitle {
font-size: clamp(0.72rem, 1.8vw, 0.95rem);
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 2.5rem;
max-width: 54ch;
}
.parallax__cta {
display: flex;
align-items: flex-end;
width: 120%;
gap: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.parallax__cta-ghost {
font-size: 0.88rem;
color: var(--text-muted);
letter-spacing: 0.06em;
transition: color 150ms;
}
.parallax__cta-ghost:hover { color: var(--petronas-teal); }
/* Layer 4 — figure: same Osmo offset as bg so GSAP travel is consistent.
SVG is constrained with max-width so it doesn't fill the whole container. */
.parallax__layer-figure {
position: absolute;
top: -9.5%;
left: 0;
width: 120%;
height: 105.5%;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 2vh;
z-index: 2;
pointer-events: none;
will-change: transform;
}
.parallax__figure-img {
width: clamp(200px, 50vh, 650px);
height: 70vh;
display: block;
flex-shrink: 0;
object-fit: contain;
object-position: bottom center;
filter: drop-shadow(0 -8px 24px rgba(0,210,190,0.15));
}
/* ── Bottom fade ─────────────────────────────────────────────── */
.parallax__fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 180px;
background: linear-gradient(to bottom, transparent, var(--bg-void));
z-index: 10;
pointer-events: none;
}
/* Scroll hint — absolute inside the 100svh header */
.parallax__scroll-hint {
position: absolute;
bottom: 2.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
z-index: 30;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.scroll-line {
width: 1px;
height: 48px;
background: linear-gradient(to bottom, var(--petronas-teal), transparent);
transform-origin: top;
animation: scroll-grow 2s ease-in-out infinite;
}
@keyframes scroll-grow {
0% { transform: scaleY(0); opacity: 1; }
65% { transform: scaleY(1); opacity: 1; }
100% { transform: scaleY(1); opacity: 0; }
}
@media (max-width: 600px) {
.parallax__figure-svg { width: 110px; }
.parallax__cta { gap: 1rem; }
}
</style>

View File

@@ -0,0 +1,131 @@
---
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 3);
---
<section class="section recent-posts" id="recent-posts">
<div class="container">
<div class="section-header">
<div class="accent-line"></div>
<h2>Recent <span class="text-accent">Entries</span></h2>
<p class="text-muted font-mono" style="margin-top:0.5rem;font-size:0.85rem">
latest dispatches from the terminal
</p>
</div>
<div class="posts-grid">
{posts.map((post, i) => (
<article class="card post-card" class:list={[{ featured: i === 0 }]}>
<a href={`/blog/${post.id}`} class="post-link">
<div class="post-card-inner">
{post.data.day && (
<div class="post-day font-mono">entry_{String(post.data.day).padStart(3,'0')}</div>
)}
<h3 class="post-card-title">{post.data.title}</h3>
<p class="post-card-desc text-muted">{post.data.description}</p>
<div class="post-card-meta">
<time class="font-mono text-muted" style="font-size:0.72rem">
{post.data.pubDate.toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' })}
</time>
<div class="post-card-tags">
{(post.data.tags ?? []).slice(0,3).map((t: string) => (
<span class="tag">{t}</span>
))}
</div>
</div>
<div class="post-card-cta text-accent font-mono">
cat post.md <span class="cta-arrow">→</span>
</div>
</div>
</a>
</article>
))}
</div>
<div style="text-align:center;margin-top:3rem">
<a href="/blog" class="btn btn-primary">View all entries →</a>
</div>
</div>
</section>
<style>
.recent-posts {
padding-bottom: 1rem;
padding-top: 15rem; }
.section-header { margin-bottom: 3rem; }
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.post-card { padding: 0; overflow: hidden; }
.post-card.featured {
border-color: rgba(0, 210, 190, 0.2);
}
.post-link {
display: block;
color: inherit;
height: 100%;
}
.post-link:hover .post-card-title { color: var(--petronas-teal); }
.post-link:hover .cta-arrow { transform: translateX(4px); }
.post-card-inner {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.75rem;
height: 100%;
}
.post-day {
font-size: 0.7rem;
color: var(--petronas-teal);
letter-spacing: 0.1em;
opacity: 0.7;
}
.post-card-title {
font-size: 1.15rem;
line-height: 1.3;
transition: color var(--transition-fast);
}
.post-card-desc {
font-size: 0.88rem;
line-height: 1.6;
flex: 1;
}
.post-card-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.post-card-tags { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.post-card-cta {
font-size: 0.75rem;
margin-top: 0.5rem;
letter-spacing: 0.05em;
}
.cta-arrow {
display: inline-block;
transition: transform var(--transition-fast);
}
@media (max-width: 900px) {
.posts-grid { grid-template-columns: 1fr; }
}
</style>

16
src/content.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).optional().default([]),
day: z.number().optional(),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };

View File

@@ -0,0 +1,63 @@
---
title: "Day 1 — Why I Built a Homelab (and Why You Probably Should Too)"
description: "Where it all starts. The hardware decisions, the philosophy, and the dangerous moment I decided a Proxmox cluster was a \"good idea\"."
pubDate: 2024-11-01
day: 1
tags: ["homelab", "proxmox", "hardware", "why"]
---
## The Itch
Every sysadmin eventually gets the itch. You spend your days wrangling production HPC clusters — ARCHER2 nodes, Cirrus compute, InfiniBand fabric — and you come home and think: *I want something like this, but mine*.
Not a VM on a cloud provider. Not a Raspberry Pi running Pi-hole. A proper stack — compute, storage, networking — that I control end to end, where breaking things at 2am is a learning experience rather than a P1 incident.
This is Day 1 of that journey.
## The Philosophy First
Before buying a single piece of hardware, I wrote down what I actually wanted from a homelab:
- **Failure should be cheap, not catastrophic.** Real redundancy, snapshots, backups.
- **Everything as code.** If I can't reproduce the setup from a Git repo, it doesn't exist.
- **Dogfood the tools I use at work.** Ansible, Terraform, monitoring stacks — use them at home so they're muscle memory at EPCC.
- **No cloud vendor lock-in.** Self-hosted or bust.
## Hardware — What I Picked and Why
### Compute
I went with a used server chassis from eBay rather than consumer NUCs. The reasoning was simple: ECC RAM, IPMI, and PCIe slots worth having. The power draw penalty is real, but I'm not running this 24/7 at full load.
> The most expensive homelab decision is the one you make twice. Buy the thing that gives you headroom.
### Storage
ZFS from day one. I'd seen enough production storage corruption at work to know that data integrity checksums aren't optional. I spec'd the pool conservatively — RAIDZ2 across four drives — knowing I could expand later when ZFS 2.2's RAIDZ expansion landed properly.
Pool name: `zfs-oporto`. Obviously.
### Networking
OPNsense on a dedicated box. No consumer router running some vendor's locked-down firmware between me and the internet. VLANs from the start, even when it felt like overkill — because it always feels like overkill until it isn't.
## The Software Stack (Day 1 Vision)
```
[ OPNsense ] ← router/firewall/VLANs
|
[ Proxmox VE ] ← hypervisor
├── Kubernetes cluster (k3s initially, then full k8s)
├── NAS VM → ZFS pool
└── Utility VMs (monitoring, DNS, etc.)
```
The monitoring story was left deliberately vague on Day 1. Spoiler: it took many iterations and a lot of wrong turns before landing on Netdata → VictoriaMetrics → Grafana.
## What Actually Happened
I had everything racked and powered by midnight. The first Proxmox install took 20 minutes. The first networking misconfiguration locked me out of the management interface for two hours.
Standard.
See you on [Day 2 →](/blog/day-002-proxmox-cluster-and-first-vms) where we get Proxmox clustered and first VMs running.

View File

@@ -0,0 +1,39 @@
---
title: "Day 2 — Proxmox Cluster, VLANs, and the First Real Mistake"
description: "Getting Proxmox VE into a proper cluster, carving VLANs in OPNsense, and the routing loop that ate an hour of my evening."
pubDate: 2024-11-08
day: 2
tags: ["proxmox", "networking", "opnsense", "vlans"]
---
## Proxmox Cluster
With two nodes up, it was time to cluster them. Proxmox's cluster setup is deceptively straightforward in the happy path. The catch is Corosync — it's very opinionated about network latency and quorum, and if you misconfigure which interface carries cluster traffic, you will have a bad time.
```bash
# On node 1:
pvecm create homelab-cluster
# On node 2:
pvecm add <node1-ip>
```
## VLAN Design
I settled on a simple scheme early and stuck to it:
| VLAN | Purpose | Subnet |
|------|--------------------------|-----------------|
| 10 | Management (IPMI, etc.) | 10.0.10.0/24 |
| 20 | Proxmox hosts | 10.0.20.0/24 |
| 30 | Kubernetes pods | 10.0.30.0/24 |
| 40 | Media / untrusted VMs | 10.0.40.0/24 |
| 50 | IoT | 10.0.50.0/24 |
The management VLAN is firewalled hard — nothing from VLAN 40 or 50 touches it.
## The Mistake
I fat-fingered a firewall rule in OPNsense that accidentally allowed VLAN 40 to reach the Proxmox management interface. I only noticed because I was testing my GeoIP block rules and a route showed up that shouldn't have existed.
Lesson: always add a deny-all rule at the bottom of every VLAN's outbound chain, even when it feels redundant. Explicit beats implicit.

View File

@@ -0,0 +1,63 @@
---
title: "Day 3 — Kubernetes on Bare Metal, Flux GitOps, and Why I Stopped Using k3s"
description: "Graduating from k3s to full kubeadm, setting up Flux CD for GitOps, and the first taste of what Longhorn storage actually means."
pubDate: 2024-11-15
day: 3
tags: ["kubernetes", "flux", "gitops", "longhorn", "k8s"]
---
## Why Not k3s Forever?
k3s is excellent. I used it for two months and it worked fine. I moved to full Kubernetes (kubeadm) for one reason: I wanted the experience to transfer directly to production environments. At work we don't run k3s. The extra complexity of kubeadm is the point.
## The Install
```bash
# kubeadm init on control plane
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--control-plane-endpoint="k8s-control.int.h0melab.uk"
# CNI — went with Cilium over Flannel for eBPF goodness
helm install cilium cilium/cilium --namespace kube-system
```
## Flux GitOps
This was the decision that changed everything. Instead of `kubectl apply`-ing manifests, every change goes through Git:
```
homelab-k8s/
├── clusters/homelab/
│ ├── flux-system/ ← Flux's own manifests
│ ├── infrastructure/ ← Traefik, Longhorn, cert-manager
│ └── apps/ ← Actual workloads
```
The golden rule I established here: **Chart.yaml version bumps are required for Flux to pick up Helm chart changes.** Forgot this approximately 15 times before it became instinct.
## Longhorn
Distributed block storage across three worker nodes. The UI is surprisingly good. The first time I watched a volume replica heal itself after a node reboot, I understood why people write blog posts about storage.
```yaml
# The PVC pattern I use for everything
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
annotations:
helm.sh/resource-policy: keep # ← never delete this on helm uninstall
spec:
storageClassName: longhorn
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
```
The `helm.sh/resource-policy: keep` annotation saved my data at least twice when I was iterating on Helm releases.
## What's Next
Day 4 covers the monitoring stack — Netdata agents, VictoriaMetrics as the TSDB, and getting Grafana to look like something I'd actually want to stare at during an incident.

15
src/content/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).optional().default([]),
day: z.number().optional(), // Day 1, Day 2, etc.
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };

View File

@@ -0,0 +1,302 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const { title, description = 'HPC Sysadmin blog — ARCHER2, Cirrus & homelab adventures' } = Astro.props;
const pathname = new URL(Astro.request.url).pathname;
const navLinks = [
{ href: '/#recent-posts', label: 'Home' },
{ href: '/blog', label: 'Blog' },
// { href: '/homelab', label: 'Homelab' },
// { href: '/hardware', label: 'Hardware' },
];
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title} | slashroot.cc</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
</head>
<body>
<!-- ── Nav ─────────────────────────────────────────────── -->
<header id="site-nav">
<nav class="nav-inner container">
<a href="/" class="nav-logo">
<span class="nav-logo-bracket">/</span>slashroot<span class="nav-logo-accent">.</span>cc
</a>
<ul class="nav-links">
{navLinks.map(({ href, label }) => (
<li>
<a
href={href}
class:list={['nav-link', { active: pathname === '/' || (href !== '/' && pathname.startsWith(href.split('#')[0])) }]}
>
{label}
</a>
</li>
))}
</ul>
<!-- Uptime Kuma live status badge -->
<div class="nav-status" id="nav-uptime">
<span class="status-dot" id="status-dot"></span>
<span class="font-mono nav-status-text" id="status-text">checking…</span>
</div>
</nav>
</header>
<!-- ── Page content ─────────────────────────────────────── -->
<main>
<slot />
</main>
<!-- ── Footer ───────────────────────────────────────────── -->
<footer class="site-footer">
<div class="container footer-inner">
<div class="footer-brand">
<span class="font-display" style="font-size:1.1rem">/slashroot<span class="text-accent">.</span>cc</span>
<p class="text-muted" style="font-size:0.8rem;margin-top:0.4rem">
Built with Astro by Raul 💙
</p>
</div>
<div class="footer-links">
<a href="https://git.h0melab.uk" target="_blank" rel="noopener" class="text-muted footer-link">Gitea</a>
<a href="/blog" class="text-muted footer-link">Blog</a>
<a href="/hardware" class="text-muted footer-link">Hardware</a>
</div>
<p class="footer-copy text-muted font-mono">
© 2025 - {new Date().getFullYear()} · slashroot.cc
</p>
</div>
</footer>
<!-- Nav scroll behaviour + Uptime Kuma status -->
<script is:inline>
const UPTIME_URL = 'https://uptime.int.h0melab.uk/api/status-page/heartbeat/homelab';
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
async function fetchStatus() {
try {
const res = await fetch(`${UPTIME_URL}?t=${Date.now()}`, {
signal: AbortSignal.timeout(5000)
});
const data = await res.json();
const monitorIds = Object.keys(data.heartbeatList || {});
let overallState = 'up';
let label = 'all systems nominal';
if (monitorIds.length === 0) {
overallState = 'unknown';
label = 'no data';
} else {
// Single loop to determine status
for (const id of monitorIds) {
const history = data.heartbeatList[id];
const latest = history[history.length - 1];
// Status Codes: 0=Down, 1=Up, 2=Pending, 3=Maintenance
if (latest.status === 0) {
overallState = 'down';
label = 'system degraded';
break; // Priority 1: If anything is down, we are down
} else if (latest.status === 2) {
overallState = 'pending';
label = 'checking...';
} else if (latest.status !== 1 && overallState !== 'pending') {
overallState = 'unknown';
label = 'status unknown';
}
}
}
setStatus(overallState, label);
} catch (err) {
// THIS WILL PRINT THE EXACT ERROR TO YOUR BROWSER CONSOLE (F12)
console.error('DETAILED ERROR:', err);
setStatus('unknown', 'status unknown');
}
}
function setStatus(state, label) {
if (!dot || !text) return;
text.textContent = label;
dot.setAttribute('data-state', state);
// Pulse/Glow logic
if (state === 'down') {
dot.style.boxShadow = '0 0 10px #ff4444';
dot.style.background = '#ff4444';
} else if (state === 'up') {
dot.style.boxShadow = '0 0 8px var(--petronas-teal)';
dot.style.background = 'var(--petronas-teal)';
} else {
dot.style.boxShadow = '0 0 8px #FFB800';
dot.style.background = '#FFB800';
}
}
// Initial call and interval
fetchStatus();
setInterval(fetchStatus, 60000);
</script>
</body>
</html>
<style>
/* Nav */
#site-nav {
position: fixed;
top: 0;
inset-inline: 0;
z-index: 100;
padding: 1.25rem 0;
transition: background var(--transition-med), backdrop-filter var(--transition-med), padding var(--transition-med), border-color var(--transition-med);
border-bottom: 1px solid transparent;
}
#site-nav.scrolled {
background: rgba(5, 10, 10, 0.85);
backdrop-filter: blur(16px);
padding: 0.75rem 0;
border-bottom-color: var(--border-subtle);
}
.nav-inner {
display: flex;
align-items: center;
gap: 2.5rem;
}
.nav-logo {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-primary);
white-space: nowrap;
margin-right: auto;
}
.nav-logo-bracket { color: var(--petronas-teal); font-weight: 400; }
.nav-logo-accent { color: var(--petronas-teal); }
.nav-links {
display: flex;
list-style: none;
gap: 0.25rem;
}
.nav-link {
display: block;
padding: 0.35rem 0.85rem;
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
border-radius: 4px;
transition: color var(--transition-fast), background var(--transition-fast);
}
.nav-link:hover { color: var(--text-primary); background: var(--bg-surface); }
.nav-link.active { color: var(--petronas-teal); }
.nav-status {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
cursor: default;
}
.nav-status-text {
font-size: 0.72rem;
color: var(--text-muted);
transition: color 0.3s;
}
/* Status dot — colour driven by data-state attribute set by JS */
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
background: var(--petronas-teal);
box-shadow: 0 0 8px var(--petronas-teal);
animation: pulse-dot 2.5s ease-in-out infinite;
transition: background 0.4s, box-shadow 0.4s;
}
/* up = teal (default) */
.status-dot[data-state="up"] {
background: var(--petronas-teal);
box-shadow: 0 0 8px var(--petronas-teal);
}
/* down = red */
.status-dot[data-state="down"] {
background: #FF4444;
box-shadow: 0 0 8px #FF4444;
animation: none;
}
/* pending / unknown = amber */
.status-dot[data-state="pending"],
.status-dot[data-state="unknown"] {
background: #FFB800;
box-shadow: 0 0 8px #FFB800;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* Footer */
.site-footer {
border-top: 1px solid var(--border-subtle);
padding: 3rem 0;
margin-top: 6rem;
}
.footer-inner {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 1.2rem;
}
.footer-links {
display: flex;
gap: 1.3rem;
justify-content: center;
}
.footer-link {
font-size: 0.90rem;
transition: color var(--transition-fast);
}
.footer-link:hover { color: var(--petronas-teal); }
.footer-copy { font-size: 0.90rem; }
@media (max-width: 768px) {
.nav-status { display: none; }
.nav-link { padding: 0.3rem 0.5rem; font-size: 0.90rem; }
.footer-inner { grid-template-columns: 1fr; text-align: center; }
.footer-links { justify-content: center; }
}
</style>

View File

@@ -0,0 +1,128 @@
---
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
description: string;
pubDate: Date;
tags?: string[];
day?: number;
}
const { title, description, pubDate, tags = [], day } = Astro.props;
const formatted = pubDate.toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric'
});
---
<BaseLayout title={title} description={description}>
<article class="post-wrapper">
<div class="post-hero container">
{day && (
<div class="day-badge font-mono">
<span class="text-muted">entry_</span>{String(day).padStart(3, '0')}
</div>
)}
<h1 class="post-title">{title}</h1>
<p class="post-description text-muted">{description}</p>
<div class="post-meta">
<time class="font-mono text-muted" datetime={pubDate.toISOString()}>{formatted}</time>
<div class="post-tags">
{tags.map(tag => <span class="tag">{tag}</span>)}
</div>
</div>
</div>
<hr class="glow-divider" />
<div class="post-body container">
<div class="prose">
<slot />
</div>
<aside class="post-sidebar">
<div class="card sidebar-card">
<p class="font-display" style="font-size:0.8rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:1rem">On this page</p>
<div id="toc-placeholder" class="font-mono text-muted" style="font-size:0.78rem">
<!-- populated by JS -->
</div>
</div>
{tags.length > 0 && (
<div class="card sidebar-card" style="margin-top:1rem">
<p class="font-display" style="font-size:0.8rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:0.75rem">Tags</p>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{tags.map(tag => <span class="tag">{tag}</span>)}
</div>
</div>
)}
<a href="/blog" class="btn btn-primary" style="margin-top:1rem;width:100%;justify-content:center">← All posts</a>
</aside>
</div>
</article>
</BaseLayout>
<script>
// Auto-generate TOC from headings
const toc = document.getElementById('toc-placeholder');
if (toc) {
const headings = document.querySelectorAll('.prose h2, .prose h3');
if (headings.length === 0) {
toc.textContent = '—';
} else {
headings.forEach((h, i) => {
if (!h.id) h.id = `heading-${i}`;
const a = document.createElement('a');
a.href = `#${h.id}`;
a.textContent = (h.tagName === 'H3' ? ' · ' : '') + h.textContent;
a.style.cssText = 'display:block;color:var(--text-muted);margin-bottom:0.4rem;font-size:0.75rem;transition:color 150ms';
a.addEventListener('mouseenter', () => a.style.color = 'var(--petronas-teal)');
a.addEventListener('mouseleave', () => a.style.color = 'var(--text-muted)');
toc.appendChild(a);
});
}
}
</script>
<style>
.post-wrapper { padding-top: 8rem; padding-bottom: 4rem; }
.post-hero { max-width: 900px; }
.day-badge {
font-size: 0.85rem;
color: var(--petronas-teal);
margin-bottom: 1rem;
letter-spacing: 0.08em;
}
.post-title { margin-bottom: 1rem; }
.post-description {
font-size: 1.15rem;
max-width: 65ch;
margin-bottom: 1.5rem;
}
.post-meta {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.post-tags { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.post-body {
display: grid;
grid-template-columns: 1fr 260px;
gap: 4rem;
align-items: start;
}
.sidebar-card { position: sticky; top: 6rem; }
@media (max-width: 900px) {
.post-body { grid-template-columns: 1fr; }
.post-sidebar { display: none; }
}
</style>

29
src/pages/404.astro Normal file
View File

@@ -0,0 +1,29 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="404">
<div class="not-found container">
<div class="accent-line"></div>
<div class="err-code font-mono">exit code 404</div>
<h1>Node <span class="text-accent">Not Found</span></h1>
<p class="text-muted" style="margin:1.5rem 0 2.5rem;font-family:var(--font-mono);font-size:0.9rem">
$ ping {Astro.url.pathname} — <span class="text-accent">100% packet loss</span>
</p>
<a href="/" class="btn btn-primary">← back to /</a>
</div>
</BaseLayout>
<style>
.not-found {
padding-top: 14rem;
padding-bottom: 8rem;
}
.err-code {
font-size: 0.75rem;
letter-spacing: 0.15em;
color: var(--text-muted);
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,25 @@
---
import { getCollection, render } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
tags={post.data.tags}
day={post.data.day}
>
<Content />
</PostLayout>

131
src/pages/blog/index.astro Normal file
View File

@@ -0,0 +1,131 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const allPosts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// Group by tags for sidebar
const allTags = [...new Set(allPosts.flatMap(p => p.data.tags ?? []))].sort();
---
<BaseLayout title="Blog" description="Sysadmin chronicles — HPC, homelab, and everything in between">
<div class="blog-page">
<div class="blog-hero container">
<div class="accent-line"></div>
<h1>The <span class="text-accent">Log</span></h1>
<p class="text-muted font-mono" style="font-size:0.9rem;margin-top:0.5rem">
{allPosts.length} entries · HPC · homelab · infra · ops
</p>
</div>
<div class="blog-layout container">
<!-- Post list -->
<div class="post-list">
{allPosts.map((post, i) => (
<article class="post-row card">
<a href={`/blog/${post.id}`} class="post-row-link">
<div class="post-row-left">
{post.data.day && (
<div class="font-mono" style="font-size:0.7rem;color:var(--petronas-teal);letter-spacing:0.1em;margin-bottom:0.3rem">
entry_{String(post.data.day).padStart(3,'0')}
</div>
)}
<h2 class="post-row-title">{post.data.title}</h2>
<p class="text-muted post-row-desc">{post.data.description}</p>
<div class="post-row-meta">
<time class="font-mono text-muted" style="font-size:0.72rem">
{post.data.pubDate.toLocaleDateString('en-GB', {day:'numeric',month:'short',year:'numeric'})}
</time>
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
{(post.data.tags ?? []).map((t: string) => <span class="tag">{t}</span>)}
</div>
</div>
</div>
<div class="post-row-arrow text-accent font-display">→</div>
</a>
</article>
))}
</div>
<!-- Sidebar -->
<aside class="blog-sidebar">
<div class="card" style="position:sticky;top:5.5rem">
<p class="font-display" style="font-size:0.75rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-muted);margin-bottom:1rem">Tags</p>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{allTags.map(t => <span class="tag">{t}</span>)}
</div>
<hr class="glow-divider" style="margin:1.5rem 0" />
<p class="font-display" style="font-size:0.75rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-muted);margin-bottom:0.75rem">Navigate</p>
<div style="display:flex;flex-direction:column;gap:0.35rem">
<a href="/" class="text-muted font-mono" style="font-size:0.8rem">← Home</a>
<a href="/homelab" class="text-muted font-mono" style="font-size:0.8rem">Homelab status</a>
<a href="/hardware" class="text-muted font-mono" style="font-size:0.8rem">Hardware list</a>
</div>
</div>
</aside>
</div>
</div>
</BaseLayout>
<style>
.blog-page { padding-top: 8rem; }
.blog-hero { margin-bottom: 3.5rem; }
.blog-layout {
display: grid;
grid-template-columns: 1fr 240px;
gap: 3rem;
align-items: start;
}
.post-list { display: flex; flex-direction: column; gap: 1rem; }
.post-row { padding: 0; }
.post-row-link {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem;
color: inherit;
transition: background var(--transition-fast);
}
.post-row:hover .post-row-title { color: var(--petronas-teal); }
.post-row-left { flex: 1; }
.post-row-title {
font-size: 1.05rem;
margin-bottom: 0.4rem;
transition: color var(--transition-fast);
}
.post-row-desc {
font-size: 0.85rem;
line-height: 1.55;
margin-bottom: 0.75rem;
}
.post-row-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.post-row-arrow {
font-size: 1.5rem;
opacity: 0.3;
transition: opacity var(--transition-fast), transform var(--transition-fast);
flex-shrink: 0;
}
.post-row:hover .post-row-arrow { opacity: 1; transform: translateX(4px); }
@media (max-width: 768px) {
.blog-layout { grid-template-columns: 1fr; }
.blog-sidebar { display: none; }
}
</style>

331
src/pages/hardware.astro Normal file
View File

@@ -0,0 +1,331 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
// ── Hardware data — edit this as your inventory grows ──────────
const hardware = {
compute: [
{
name: 'Proxmox Node 01',
model: 'Dell PowerEdge R730',
specs: ['2× Intel Xeon E5-2680v4 (28C/56T total)', '256GB DDR4 ECC LRDIMM', '2× 10GbE SFP+', 'iDRAC8 Enterprise', 'H730 RAID (passthrough)'],
role: 'Primary Proxmox hypervisor · VM host',
status: 'online',
location: 'homelab rack · U4',
},
{
name: 'Proxmox Node 02',
model: 'Dell PowerEdge R720',
specs: ['2× Intel Xeon E5-2670 (16C/32T total)', '128GB DDR3 ECC RDIMM', '2× 1GbE', 'iDRAC7 Enterprise'],
role: 'Secondary Proxmox node · backup workloads',
status: 'online',
location: 'homelab rack · U6',
},
{
name: 'Raspberry Pi 4 (8GB)',
model: 'Raspberry Pi 4 Model B',
specs: ['Broadcom BCM2711 (4× Cortex-A72)', '8GB LPDDR4', '2× USB 3.0, Gigabit Ethernet', '64GB Samsung Pro Endurance microSD'],
role: 'PXE boot server · ISC26 cluster deployment',
status: 'online',
location: 'desk · USB-C powered',
},
],
storage: [
{
name: 'ZFS NAS — zfs-oporto',
model: 'Custom Proxmox VM → ZFS passthrough',
specs: ['4× Seagate IronWolf 8TB (RAIDZ2)', '2× Samsung 870 EVO 1TB (SSD special class vdev)', 'ZFS 2.2 — RAIDZ expansion capable', '~20TB usable raw (RAIDZ2 overhead)'],
role: 'Primary NAS · NFS exports to K8s · Immich storage',
status: 'online',
location: 'pve-01 · RAIDZ2',
},
{
name: 'Longhorn Cluster Storage',
model: 'Distributed across k8s-worker1/2',
specs: ['2× 500GB NVMe (worker1)', '2× 500GB NVMe (worker2)', '3-replica default policy'],
role: 'Kubernetes persistent volumes · Longhorn CSI',
status: 'online',
location: 'k8s-worker1 / k8s-worker2',
},
],
networking: [
{
name: 'OPNsense Firewall',
model: 'Protectli VP2420',
specs: ['Intel Celeron J6413', '8GB DDR4', '4× 2.5GbE Intel i226', '64GB eMMC', 'OPNsense 26.1'],
role: 'Edge router · Firewall · WireGuard VPN · Unbound DNS · GeoIP blocks',
status: 'online',
location: 'homelab rack · U1',
},
{
name: 'Core Switch',
model: 'Cisco SG300-28PP',
specs: ['24× PoE+ 1GbE', '2× 1GbE combo SFP', 'Layer 3 managed', '375W PoE budget'],
role: 'Core VLAN switch · LAG to hypervisors',
status: 'online',
location: 'homelab rack · U2',
},
{
name: 'AP',
model: 'Ubiquiti U6-Lite',
specs: ['WiFi 6 (802.11ax)', '2.4 + 5GHz', 'PoE powered'],
role: 'Wireless · separate IoT SSID on VLAN50',
status: 'online',
location: 'ceiling mount',
},
],
work_hpc: [
{
name: 'ARCHER2 (EPCC)',
model: 'HPE Cray EX — AMD EPYC 7742',
specs: ['5,860 compute nodes', '2× AMD EPYC 7742 (128C/node)', '256GB DDR4 ECC/node', 'HPE Slingshot 11 100Gb/s HSN', '14.1 PiB Lustre storage'],
role: 'UK National HPC facility · sysadmin',
status: 'production',
location: 'EPCC · Edinburgh',
},
{
name: 'Cirrus (EPCC)',
model: 'SGI ICE XA / HPE ProLiant XL230a',
specs: ['280 compute nodes', '2× Intel Xeon E5-2695v4 (36C/node)', '256GB DDR4/node', 'Intel OmniPath HSN', 'Cirrus EX GPU partition: HPE Cray EX235n'],
role: 'EPCC Tier-2 HPC · sysadmin · GPU partition admin',
status: 'production',
location: 'EPCC · Edinburgh',
},
{
name: 'ISC26 Competition Cluster',
model: 'Atos BullSequana XH2000',
specs: ['1× BullSequana XD670 GPU node (4× NVIDIA H100 80GB)', '8× BullSequana XD2000 CPU nodes', 'NDR 400Gb/s InfiniBand fabric (Cornelis)', 'Raspberry Pi 4 PXE deployment server'],
role: 'ISC26 Student Cluster Competition — Hamburg 2026',
status: 'building',
location: 'EPCC · Edinburgh',
},
],
peripherals: [
{
name: 'Main Monitor',
model: 'LG 27UK850-W',
specs: ['27" 4K IPS', 'USB-C 60W PD', 'HDR400', 'USB-C + HDMI + DP'],
role: 'Primary display',
status: 'online',
},
{
name: '3D Printer',
model: 'Anycubic Kobra S1',
specs: ['FDM · 220×220×250mm build volume', 'Auto-levelling', 'Direct drive'],
role: 'Printing rack accessories, cable guides, custom mounts',
status: 'idle',
},
{
name: 'Laser Printer',
model: 'Kyocera ECOSYS M2135dn',
specs: ['A4 mono laser MFP', 'Duplex · LAN · ADF', '35ppm'],
role: 'Documents · printing homelab diagrams',
status: 'idle',
},
],
};
const categories = [
{ key: 'compute', label: 'Compute', icon: '▣' },
{ key: 'storage', label: 'Storage', icon: '◈' },
{ key: 'networking', label: 'Networking', icon: '⇅' },
{ key: 'work_hpc', label: 'Work / HPC', icon: '⬡' },
{ key: 'peripherals',label: 'Peripherals & Misc', icon: '◎' },
];
---
<BaseLayout title="Hardware" description="Full hardware inventory — homelab and work HPC systems">
<div class="hardware-page">
<!-- Hero -->
<div class="page-hero container">
<div class="accent-line"></div>
<h1>Hardware <span class="text-accent">Inventory</span></h1>
<p class="text-muted font-mono" style="font-size:0.88rem;margin-top:0.5rem">
Everything physical I touch, rack and bench · last updated 2025
</p>
</div>
<!-- Jump nav -->
<div class="container jump-nav">
{categories.map(cat => (
<a href={`#${cat.key}`} class="jump-chip">
<span class="text-accent">{cat.icon}</span>
<span>{cat.label}</span>
</a>
))}
</div>
<!-- Category sections -->
{categories.map(cat => (
<section class="section container hw-section" id={cat.key}>
<div class="hw-section-header">
<span class="hw-icon text-accent">{cat.icon}</span>
<h2>{cat.label}</h2>
</div>
<div class="hw-grid">
{(hardware as any)[cat.key].map((item: any) => (
<div class="card hw-card">
<div class="hw-card-header">
<div>
<div class="hw-name font-display">{item.name}</div>
<div class="hw-model font-mono">{item.model}</div>
</div>
<span
class="hw-status tag"
style={
item.status === 'online' ? '' :
item.status === 'production' ? 'color:#00D2BE;border-color:#00D2BE;background:rgba(0,210,190,0.08)' :
item.status === 'building' ? 'color:#FFB800;border-color:#FFB800;background:rgba(255,184,0,0.08)' :
'color:var(--text-muted);border-color:var(--border-subtle);background:transparent'
}
>{item.status}</span>
</div>
<ul class="hw-specs">
{item.specs.map((s: string) => (
<li class="hw-spec">
<span class="spec-bullet text-accent">▸</span>
<span>{s}</span>
</li>
))}
</ul>
<div class="hw-footer">
<span class="hw-role text-muted">{item.role}</span>
{item.location && (
<span class="hw-location font-mono">{item.location}</span>
)}
</div>
</div>
))}
</div>
</section>
))}
<!-- Total count footer -->
<div class="container hw-totals">
<div class="card" style="padding:1.5rem;text-align:center">
<p class="font-mono text-muted" style="font-size:0.8rem">
{Object.values(hardware).flat().length} items catalogued ·
<span class="text-accent">rack::log hardware registry</span>
</p>
</div>
</div>
</div>
</BaseLayout>
<style>
.hardware-page { padding-top: 8rem; padding-bottom: 4rem; }
.page-hero { margin-bottom: 2.5rem; }
/* Jump nav */
.jump-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 4rem;
}
.jump-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.9rem;
border: 1px solid var(--border-subtle);
border-radius: 20px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-muted);
background: var(--bg-surface);
transition: all var(--transition-fast);
}
.jump-chip:hover {
border-color: var(--border-glow);
color: var(--petronas-teal);
background: var(--petronas-glow);
}
/* Section header */
.hw-section { padding-top: 3rem; padding-bottom: 1rem; }
.hw-section-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.75rem;
}
.hw-icon { font-size: 1.4rem; }
/* Cards */
.hw-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
}
.hw-card { display: flex; flex-direction: column; gap: 1rem; }
.hw-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.hw-name {
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
}
.hw-model {
font-size: 0.72rem;
color: var(--petronas-teal);
margin-top: 0.2rem;
}
.hw-status { white-space: nowrap; flex-shrink: 0; }
/* Specs list */
.hw-specs {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.hw-spec {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.4;
}
.spec-bullet { font-size: 0.6rem; flex-shrink: 0; margin-top: 0.15rem; }
/* Footer */
.hw-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-subtle);
}
.hw-role { font-size: 0.8rem; }
.hw-location { font-size: 0.68rem; color: var(--text-muted); }
/* Totals */
.hw-totals { margin-top: 4rem; }
@media (max-width: 600px) {
.hw-grid { grid-template-columns: 1fr; }
}
</style>

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>

17
src/pages/index.astro Normal file
View File

@@ -0,0 +1,17 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ParallaxHero from '../components/ParallaxHero.astro';
import HomelabStatus from '../components/HomelabStatus.astro';
import RecentPosts from '../components/RecentPosts.astro';
---
<BaseLayout title="Home">
<ParallaxHero />
<!-- <RecentPosts /> -->
<hr class="glow-divider container" />
<HomelabStatus />
</BaseLayout>

292
src/styles/global.css Normal file
View File

@@ -0,0 +1,292 @@
/* ============================================================
GLOBAL STYLES — AMG Petronas Green Theme
============================================================ */
@import url("https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Inter:wght@300;400;500&display=swap");
/* ── CSS Variables ─────────────────────────────────────────── */
:root {
--petronas-teal: #00d2be;
--petronas-green: #00a19c;
--petronas-dark: #007a76;
--petronas-glow: rgba(0, 210, 190, 0.15);
--petronas-glow-lg: rgba(0, 210, 190, 0.35);
--bg-void: #050a0a;
--bg-deep: #080f0e;
--bg-surface: #0d1918;
--bg-card: #111e1d;
--bg-card-hover: #162524;
--bg-glass: rgba(13, 25, 24, 0.7);
--text-primary: #e8f5f4;
--text-secondary: #8bbab8;
--text-muted: #4a7a78;
--text-accent: var(--petronas-teal);
--border-subtle: rgba(0, 210, 190, 0.08);
--border-glow: rgba(0, 210, 190, 0.35);
--font-display: "Rajdhani", sans-serif;
--font-mono: "DM Mono", monospace;
--font-body: "Inter", sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-med: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset ─────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
color-scheme: dark;
}
body {
background-color: var(--bg-void);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 300;
line-height: 1.7;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
/* ── Scrollbar ─────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: var(--bg-void);
}
::-webkit-scrollbar-thumb {
background: var(--petronas-dark);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--petronas-teal);
}
/* ── Typography ────────────────────────────────────────────── */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.15;
color: var(--text-primary);
}
h1 {
font-size: clamp(2.5rem, 6vw, 5rem);
}
h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
}
h3 {
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
}
a {
color: var(--petronas-teal);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--text-primary);
}
code,
pre {
font-family: var(--font-mono);
font-size: 0.88em;
}
pre {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-left: 3px solid var(--petronas-teal);
padding: 1.25rem 1.5rem;
border-radius: 0 8px 8px 0;
overflow-x: auto;
margin: 2rem 0;
}
/* ── Layout ────────────────────────────────────────────────── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.section {
padding: 6rem 0;
}
/* ── Teal Accent Line ──────────────────────────────────────── */
.accent-line {
display: inline-block;
width: 3rem;
height: 3px;
background: linear-gradient(90deg, var(--petronas-teal), transparent);
margin-bottom: 1rem;
}
/* ── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 1.75rem;
transition:
border-color var(--transition-med),
background var(--transition-med),
transform var(--transition-med);
}
.card:hover {
border-color: var(--border-glow);
background: var(--bg-card-hover);
transform: translateY(-3px);
}
/* ── Glow Button ───────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.5rem;
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-med);
}
.btn-primary {
background: transparent;
color: var(--petronas-teal);
border: 1px solid var(--petronas-teal);
}
.btn-primary:hover {
background: var(--petronas-glow);
box-shadow: 0 0 24px var(--petronas-glow-lg);
color: var(--petronas-teal);
}
/* ── Tag / Badge ───────────────────────────────────────────── */
.tag {
display: inline-block;
padding: 0.2rem 0.65rem;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--petronas-teal);
background: var(--petronas-glow);
border: 1px solid rgba(0, 210, 190, 0.2);
border-radius: 3px;
letter-spacing: 0.04em;
}
/* ── Glow Divider ──────────────────────────────────────────── */
.glow-divider {
border: none;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--petronas-teal),
transparent
);
margin: 4rem 0;
opacity: 0.3;
margin: 4rem auto;
}
/* ── Noise texture overlay ─────────────────────────────────── */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* ── Utility ───────────────────────────────────────────────── */
.text-muted {
color: var(--text-muted);
}
.text-accent {
color: var(--petronas-teal);
}
.font-mono {
font-family: var(--font-mono);
}
.font-display {
font-family: var(--font-display);
}
/* ── Prose (blog posts) ────────────────────────────────────── */
.prose {
max-width: 72ch;
color: var(--text-secondary);
}
.prose h2,
.prose h3 {
color: var(--text-primary);
margin: 2.5rem 0 1rem;
}
.prose p {
margin-bottom: 1.4rem;
}
.prose ul,
.prose ol {
padding-left: 1.5rem;
margin-bottom: 1.4rem;
}
.prose li {
margin-bottom: 0.4rem;
}
.prose blockquote {
border-left: 3px solid var(--petronas-teal);
padding-left: 1.25rem;
color: var(--text-muted);
font-style: italic;
margin: 2rem 0;
}
.prose img {
border-radius: 8px;
width: 100%;
border: 1px solid var(--border-subtle);
}
.prose a {
text-decoration: underline;
text-underline-offset: 3px;
}
.prose strong {
color: var(--text-primary);
font-weight: 500;
}
.prose code {
background: var(--bg-surface);
padding: 0.15em 0.4em;
border-radius: 3px;
color: var(--petronas-teal);
}

9
tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}