Compare commits
17 Commits
e97eb03bb4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b56a90c14 | |||
|
|
15db73e7bc | ||
|
|
bedbd016a8 | ||
|
|
6d73b544c6 | ||
|
|
1ac70a0b1a | ||
|
|
2d644a1ec3 | ||
|
|
ada3e2c1b7 | ||
|
|
2b25b18fc8 | ||
|
|
6d44c47bfe | ||
|
|
d6fe3f0dc0 | ||
|
|
edc3e97d3b | ||
|
|
bbea284c49 | ||
|
|
815912d20e | ||
|
|
94837f5493 | ||
|
|
94338d556e | ||
|
|
ecb6fc0832 | ||
|
|
8c11192e7b |
84
.gitea/workflows/deploy.yaml
Normal file
84
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
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
|
||||
# Change this to your actual Gitea username / infra repo name
|
||||
INFRA_REPO: h0melab/infra-cluster-fluxcd
|
||||
|
||||
jobs:
|
||||
build-push-update:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Timestamp Tag
|
||||
run: echo "BUILD_TIME=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.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 }}:${{ env.BUILD_TIME }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Checkout Infra Repository Manually
|
||||
run: |
|
||||
echo "🧹 Cleaning up old workspace..."
|
||||
rm -rf infra-workspace
|
||||
|
||||
echo "🚀 Attempting to clone the infrastructure repository..."
|
||||
# Using the token directly in the URL to bypass the API and clone over HTTPS
|
||||
git clone https://gitea_bot:${{ secrets.INFRA_REPO_TOKEN }}@git.h0melab.uk/${{ env.INFRA_REPO }}.git infra-workspace
|
||||
|
||||
echo "✅ Clone successful! Contents:"
|
||||
ls -la infra-workspace
|
||||
|
||||
- name: Update Helm values.yaml for Flux
|
||||
run: |
|
||||
cd infra-workspace
|
||||
|
||||
echo "--- TARGET FILE BEFORE ---"
|
||||
cat charts/slashroot/values.yaml
|
||||
|
||||
# The sed command (Make sure the path matches perfectly!)
|
||||
sed -i 's/tag: .*/tag: "${{ env.BUILD_TIME }}"/g' charts/slashroot/values.yaml
|
||||
|
||||
echo "--- TARGET FILE AFTER ---"
|
||||
cat charts/slashroot/values.yaml
|
||||
|
||||
# Set up Git
|
||||
git config user.name "Gitea Actions Bot"
|
||||
git config user.email "actions@gitea.local"
|
||||
git add charts/slashroot/values.yaml
|
||||
|
||||
# Check if there are actually changes to commit
|
||||
if git diff --staged --quiet; then
|
||||
echo "❌ ERROR: No changes were made! Check if 'tag: ' actually exists in your values.yaml"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Changes detected! Committing and pushing..."
|
||||
git commit -m "BOT: (deploy) slashroot-cc update ${{ env.BUILD_TIME }}"
|
||||
git push origin main
|
||||
fi
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# Stage 1: Build the Astro project
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
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;"]
|
||||
110
README.md
110
README.md
@@ -1,3 +1,111 @@
|
||||
# slashroot-cc
|
||||
|
||||
Welcom to my web blog deployment https://slashroot.cc
|
||||
Welcom to my web blog deployment https://slashroot.cc
|
||||
|
||||
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:
|
||||
|
||||
- Build command: `npm run build`
|
||||
- Output directory: `dist`
|
||||
|
||||
8
astro.config.mjs
Normal file
8
astro.config.mjs
Normal 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
5546
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"astro": "^6.1.3",
|
||||
"gsap": "^3.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
16
public/favicon.svg
Normal file
16
public/favicon.svg
Normal 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 |
BIN
public/image_zb1c25zb1c25zb1c.png
Normal file
BIN
public/image_zb1c25zb1c25zb1c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
BIN
public/image_zb1c25zb1c25zb1c.pngZone.Identifier
Normal file
BIN
public/image_zb1c25zb1c25zb1c.pngZone.Identifier
Normal file
Binary file not shown.
BIN
public/image_zb1c2e65feww76x.png
Normal file
BIN
public/image_zb1c2e65feww76x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
338
src/components/HomelabStatus.astro
Normal file
338
src/components/HomelabStatus.astro
Normal 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>
|
||||
334
src/components/ParallaxHero.astro
Normal file
334
src/components/ParallaxHero.astro
Normal 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>
|
||||
130
src/components/RecentPosts.astro
Normal file
130
src/components/RecentPosts.astro
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
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) => (
|
||||
<a href={`/tags/${t}`} class="tag" onclick="event.stopPropagation()">{t}</a>
|
||||
))}
|
||||
</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: 2rem; }
|
||||
|
||||
.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
16
src/content.config.ts
Normal 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 };
|
||||
63
src/content/blog/day-001-why-i-built-a-homelab.md
Normal file
63
src/content/blog/day-001-why-i-built-a-homelab.md
Normal 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.
|
||||
39
src/content/blog/day-002-proxmox-cluster-and-first-vms.md
Normal file
39
src/content/blog/day-002-proxmox-cluster-and-first-vms.md
Normal 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.
|
||||
63
src/content/blog/day-003-kubernetes-flux-gitops.md
Normal file
63
src/content/blog/day-003-kubernetes-flux-gitops.md
Normal 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
15
src/content/config.ts
Normal 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 };
|
||||
302
src/layouts/BaseLayout.astro
Normal file
302
src/layouts/BaseLayout.astro
Normal 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>
|
||||
156
src/layouts/PostLayout.astro
Normal file
156
src/layouts/PostLayout.astro
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
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 => <a href={`/tags/${tag}`} class="tag">{tag}</a>)}
|
||||
</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 => <a href={`/tags/${tag}`} class="tag">{tag}</a>)}
|
||||
</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: 2rem; }
|
||||
|
||||
/* Hero is explicitly left-aligned — Rajdhani can inherit centre from somewhere */
|
||||
.post-hero {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.day-badge {
|
||||
font-size: 0.85rem;
|
||||
color: var(--petronas-teal);
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin-bottom: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.post-description {
|
||||
font-size: 1.15rem;
|
||||
max-width: 65ch;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.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 — sticky */
|
||||
.sidebar-card { position: sticky; top: 6rem; }
|
||||
|
||||
/* TOC heading labels — was almost same colour as bg */
|
||||
.sidebar-card p[style] {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* TOC links — clearly visible */
|
||||
#toc-placeholder a {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
#toc-placeholder a:hover {
|
||||
color: var(--petronas-teal) !important;
|
||||
}
|
||||
|
||||
/* Sidebar section labels */
|
||||
.sidebar-card .font-display {
|
||||
color: var(--text-secondary) !important;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.post-body { grid-template-columns: 1fr; }
|
||||
.post-sidebar { display: none; }
|
||||
}
|
||||
</style>
|
||||
29
src/pages/404.astro
Normal file
29
src/pages/404.astro
Normal 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>
|
||||
25
src/pages/blog/[slug].astro
Normal file
25
src/pages/blog/[slug].astro
Normal 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>
|
||||
202
src/pages/blog/index.astro
Normal file
202
src/pages/blog/index.astro
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
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">
|
||||
<div class="post-list">
|
||||
{allPosts.map((post, i) => (
|
||||
<article class="post-row card">
|
||||
<a href={`/blog/${post.id}`} class="post-row-link">
|
||||
|
||||
{/* Top row: entry badge + title stacked over description */}
|
||||
<div class="post-row-top">
|
||||
<div class="post-row-left">
|
||||
{post.data.day && (
|
||||
<div class="post-row-day font-mono">
|
||||
entry_{String(post.data.day).padStart(3,'0')}
|
||||
</div>
|
||||
)}
|
||||
<h2 class="post-row-title">{post.data.title}</h2>
|
||||
</div>
|
||||
<p class="text-muted post-row-desc">{post.data.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: date + tags inline + MORE button */}
|
||||
<div class="post-row-meta">
|
||||
<time class="font-mono text-muted post-row-date">
|
||||
{post.data.pubDate.toLocaleDateString('en-GB', {day:'numeric', month:'short', year:'numeric'})}
|
||||
</time>
|
||||
<div class="post-row-tags">
|
||||
{(post.data.tags ?? []).map((t: string) => (
|
||||
<a href={`/tags/${t}`} class="tag" onclick="event.stopPropagation()">{t}</a>
|
||||
))}
|
||||
</div>
|
||||
<a href={`/blog/${post.id}`} >
|
||||
<div class="post-row-more font-mono">MORE →</div>
|
||||
</a>
|
||||
</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: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>
|
||||
<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:1rem">Tags</p>
|
||||
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{allTags.map(t => <a href={`/tags/${t}`} class="tag">{t}</a>)}
|
||||
</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:1rem">Posts</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{allPosts.map(t => <a href={`/blog/${t}`} class="blog">{t}</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;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* * FIXED: Top section now stacks the title and description in 2 rows
|
||||
*/
|
||||
.post-row-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.post-row-left { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.post-row-day {
|
||||
font-size: 0.68rem;
|
||||
color: var(--petronas-teal);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.post-row-title {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.post-row:hover .post-row-title { color: var(--petronas-teal); }
|
||||
|
||||
.post-row-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Bottom section: date + tags + button all on one line */
|
||||
.post-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: nowrap;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.post-row-date {
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-row-tags {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* * FIXED: Force the tag contents to center perfectly
|
||||
* (You can move this to your global CSS file if `.tag` is shared globally)
|
||||
*/
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* * FIXED: Replaced arrow with a styled 'MORE' button
|
||||
*/
|
||||
.post-row-more {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post-row:hover .post-row-more {
|
||||
border-color: var(--petronas-teal);
|
||||
color: var(--petronas-teal);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-layout { grid-template-columns: 1fr; }
|
||||
.blog-sidebar { display: none; }
|
||||
.post-row-meta { flex-wrap: wrap; }
|
||||
.post-row-more { margin-left: 0; } /* Optional: push button to left on mobile if wrapped */
|
||||
}
|
||||
</style>
|
||||
331
src/pages/hardware.astro
Normal file
331
src/pages/hardware.astro
Normal 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
506
src/pages/homelab.astro
Normal 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
17
src/pages/index.astro
Normal 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>
|
||||
109
src/pages/tags/[tag].astro
Normal file
109
src/pages/tags/[tag].astro
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
||||
const tags = [...new Set(posts.flatMap(p => p.data.tags ?? []))];
|
||||
|
||||
return tags.map(tag => ({
|
||||
params: { tag },
|
||||
props: {
|
||||
tag,
|
||||
posts: posts
|
||||
.filter(p => p.data.tags?.includes(tag))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { tag, posts } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={`#${tag}`} description={`All posts tagged ${tag}`}>
|
||||
<div class="tag-page">
|
||||
<div class="container page-hero">
|
||||
<a href="/blog" class="back-link font-mono">← all posts</a>
|
||||
<div class="accent-line" style="margin-top:1rem"></div>
|
||||
<h1>
|
||||
<span class="text-muted" style="font-weight:400">#</span>{tag}
|
||||
</h1>
|
||||
<p class="text-muted font-mono" style="font-size:0.85rem;margin-top:0.5rem">
|
||||
{posts.length} {posts.length === 1 ? 'entry' : 'entries'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="container post-list">
|
||||
{posts.map(post => (
|
||||
<article class="card post-row">
|
||||
<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) => (
|
||||
<a href={`/tags/${t}`} class="tag">{t}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-row-arrow text-accent font-display">→</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.tag-page { padding-top: 8rem; padding-bottom: 4rem; }
|
||||
.page-hero { margin-bottom: 3rem; }
|
||||
|
||||
.back-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
transition: color 150ms;
|
||||
}
|
||||
.back-link:hover { color: var(--petronas-teal); }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.post-row-left { flex: 1; }
|
||||
.post-row-title { font-size: 1.05rem; margin-bottom: 0.4rem; transition: color 150ms; }
|
||||
.post-row:hover .post-row-title { color: var(--petronas-teal); }
|
||||
.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 150ms, transform 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.post-row:hover .post-row-arrow { opacity: 1; transform: translateX(4px); }
|
||||
</style>
|
||||
313
src/styles/global.css
Normal file
313
src/styles/global.css
Normal file
@@ -0,0 +1,313 @@
|
||||
/* ============================================================
|
||||
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: #0d1918;
|
||||
border: 1px solid rgba(0, 210, 190, 0.12);
|
||||
border-left: 3px solid var(--petronas-teal);
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
overflow-x: auto;
|
||||
margin: 2rem 0;
|
||||
color: #a8c8c6; /* single readable text colour — no syntax highlighting */
|
||||
}
|
||||
|
||||
/* Kill any syntax highlighter span colours — keep it monochrome */
|
||||
pre span,
|
||||
pre code span {
|
||||
color: inherit !important;
|
||||
background: transparent !important;
|
||||
font-style: normal !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
/* ── 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.15rem 0.55rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--petronas-teal);
|
||||
background: var(--petronas-glow);
|
||||
border: 1px solid rgba(0, 210, 190, 0.2);
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
a.tag:hover {
|
||||
background: rgba(0, 210, 190, 0.2);
|
||||
border-color: rgba(0, 210, 190, 0.5);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Glow Divider ──────────────────────────────────────────── */
|
||||
.glow-divider {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--petronas-teal),
|
||||
transparent
|
||||
);
|
||||
margin: 4rem 0;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── 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: #9bbfbd; /* brighter than text-secondary for long-form readability */
|
||||
}
|
||||
.prose h2,
|
||||
.prose h3 {
|
||||
color: var(--text-primary);
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
.prose p {
|
||||
margin-bottom: 1.4rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.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: #0d1918;
|
||||
padding: 0.15em 0.45em;
|
||||
border-radius: 3px;
|
||||
color: #7dcfca; /* slightly muted teal — readable but distinct from body text */
|
||||
font-size: 0.85em;
|
||||
}
|
||||
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user