RC: (upload) astro initial structure
This commit is contained in:
53
.gitea/workflows/deploy.yaml
Normal file
53
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and Update Flux
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
# CRITICAL: Do not trigger this action if we are just updating the Helm chart,
|
||||||
|
# otherwise it will create an infinite loop!
|
||||||
|
- "helm/**"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.h0melab.uk
|
||||||
|
IMAGE_NAME: git.h0melab.uk/rgcosta/slashroot-cc
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push-update:
|
||||||
|
runs-on: gitea-runner-docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push Docker Image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Update Helm values.yaml for Flux
|
||||||
|
run: |
|
||||||
|
# 1. Update the image tag in your local Helm chart using sed
|
||||||
|
sed -i "s/tag: .*/tag: ${{ gitea.sha }}/g" charts/slashroot/values.yaml
|
||||||
|
|
||||||
|
# 2. Configure the Gitea Bot to commit the change
|
||||||
|
git config user.name "Gitea Actions Bot"
|
||||||
|
git config user.email "actions@gitea.local"
|
||||||
|
|
||||||
|
# 3. Commit and push the updated values.yaml back to the main branch
|
||||||
|
git add charts/slashroot/values.yaml
|
||||||
|
git commit -m "chore: update slashroot image tag to ${{ gitea.sha }} [skip ci]"
|
||||||
|
git push
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
16
Dockerfile
Normal file
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 ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
# Copy the Astro output from the builder stage
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
# Expose port 80 for your Helm chart / Traefik to pick up
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# rack::log — Sysadmin Blog
|
||||||
|
|
||||||
|
AMG Petronas green themed personal blog built with Astro.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Astro 4** — static site framework
|
||||||
|
- **MDX** — blog posts in Markdown with component support
|
||||||
|
- **Content Collections** — typed blog frontmatter
|
||||||
|
- **GSAP** (optional) — parallax scroll
|
||||||
|
- Zero CSS frameworks — all custom, CSS variables
|
||||||
|
|
||||||
|
## Quick Start (WSL / Debian)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sysadmin-blog
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:4321
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── ParallaxHero.astro ← rack corridor parallax landing hero
|
||||||
|
│ ├── RecentPosts.astro ← last 3 blog posts (auto-fetched)
|
||||||
|
│ └── HomelabStatus.astro ← live metrics widget (demo + real API)
|
||||||
|
│
|
||||||
|
├── content/
|
||||||
|
│ ├── config.ts ← blog collection schema
|
||||||
|
│ └── blog/
|
||||||
|
│ ├── day-001-*.md ← Day 1 entry
|
||||||
|
│ ├── day-002-*.md ← Day 2 entry
|
||||||
|
│ └── day-003-*.md ← Day 3 entry
|
||||||
|
│
|
||||||
|
├── layouts/
|
||||||
|
│ ├── BaseLayout.astro ← nav + footer wrapper
|
||||||
|
│ └── PostLayout.astro ← blog post with TOC sidebar
|
||||||
|
│
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.astro ← landing (hero + posts + homelab)
|
||||||
|
│ ├── blog/
|
||||||
|
│ │ ├── index.astro ← all posts listing
|
||||||
|
│ │ └── [slug].astro ← dynamic post page
|
||||||
|
│ ├── homelab.astro ← architecture diagram + live metrics
|
||||||
|
│ ├── hardware.astro ← full hardware inventory
|
||||||
|
│ └── 404.astro
|
||||||
|
│
|
||||||
|
└── styles/
|
||||||
|
└── global.css ← all CSS variables, typography, base
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing a New Blog Post
|
||||||
|
|
||||||
|
Create `src/content/blog/day-NNN-your-title.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Day 4 — Setting Up the Monitoring Stack"
|
||||||
|
description: "Netdata → VictoriaMetrics → Grafana"
|
||||||
|
pubDate: 2024-11-22
|
||||||
|
day: 4
|
||||||
|
tags: ["monitoring", "grafana", "victoriametrics", "netdata"]
|
||||||
|
---
|
||||||
|
|
||||||
|
Your content here...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `day` field is optional — use it for the numbered series entries.
|
||||||
|
|
||||||
|
## Connecting Live Homelab Metrics
|
||||||
|
|
||||||
|
In `src/components/HomelabStatus.astro` and `src/pages/homelab.astro`:
|
||||||
|
|
||||||
|
1. Set `USE_DEMO = false`
|
||||||
|
2. Set `NETDATA_BASE` to your Netdata URL (e.g. `http://netdata.int.h0melab.uk`)
|
||||||
|
3. The fetch calls use the Netdata REST API v1 — adapt to your endpoint structure
|
||||||
|
|
||||||
|
For a custom FastAPI endpoint (like your FC Porto fixture scraper pattern):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example: GET /api/homelab/status
|
||||||
|
const data = await fetch('https://api.h0melab.uk/homelab/status').then(r => r.json());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Colour Variables
|
||||||
|
|
||||||
|
All theme colours live in `src/styles/global.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--petronas-teal: #00D2BE
|
||||||
|
--petronas-green: #00A19C
|
||||||
|
--petronas-dark: #007A76
|
||||||
|
--bg-void: #050A0A
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # outputs to dist/
|
||||||
|
# Deploy dist/ to any static host: Cloudflare Pages, Netlify, Vercel, nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
For Cloudflare Pages (recommended for rcosta.uk):
|
||||||
|
- Build command: `npm run build`
|
||||||
|
- Output directory: `dist`
|
||||||
8
astro.config.mjs
Normal file
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": {
|
||||||
|
"astro": "^4.15.0",
|
||||||
|
"@astrojs/mdx": "^3.1.0",
|
||||||
|
"@astrojs/sitemap": "^3.1.0",
|
||||||
|
"gsap": "^3.12.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
public/favicon.svg
Normal file
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>
|
||||||
131
src/components/RecentPosts.astro
Normal file
131
src/components/RecentPosts.astro
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
const posts = (await getCollection('blog'))
|
||||||
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
||||||
|
.slice(0, 3);
|
||||||
|
---
|
||||||
|
<section class="section recent-posts" id="recent-posts">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="accent-line"></div>
|
||||||
|
<h2>Recent <span class="text-accent">Entries</span></h2>
|
||||||
|
<p class="text-muted font-mono" style="margin-top:0.5rem;font-size:0.85rem">
|
||||||
|
latest dispatches from the terminal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="posts-grid">
|
||||||
|
{posts.map((post, i) => (
|
||||||
|
<article class="card post-card" class:list={[{ featured: i === 0 }]}>
|
||||||
|
<a href={`/blog/${post.id}`} class="post-link">
|
||||||
|
<div class="post-card-inner">
|
||||||
|
{post.data.day && (
|
||||||
|
<div class="post-day font-mono">entry_{String(post.data.day).padStart(3,'0')}</div>
|
||||||
|
)}
|
||||||
|
<h3 class="post-card-title">{post.data.title}</h3>
|
||||||
|
<p class="post-card-desc text-muted">{post.data.description}</p>
|
||||||
|
<div class="post-card-meta">
|
||||||
|
<time class="font-mono text-muted" style="font-size:0.72rem">
|
||||||
|
{post.data.pubDate.toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' })}
|
||||||
|
</time>
|
||||||
|
<div class="post-card-tags">
|
||||||
|
{(post.data.tags ?? []).slice(0,3).map((t: string) => (
|
||||||
|
<span class="tag">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-card-cta text-accent font-mono">
|
||||||
|
cat post.md <span class="cta-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin-top:3rem">
|
||||||
|
<a href="/blog" class="btn btn-primary">View all entries →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.recent-posts {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
padding-top: 15rem; }
|
||||||
|
|
||||||
|
.section-header { margin-bottom: 3rem; }
|
||||||
|
|
||||||
|
.posts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card { padding: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
.post-card.featured {
|
||||||
|
border-color: rgba(0, 210, 190, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link:hover .post-card-title { color: var(--petronas-teal); }
|
||||||
|
.post-link:hover .cta-arrow { transform: translateX(4px); }
|
||||||
|
|
||||||
|
.post-card-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-day {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-tags { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.post-card-cta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.posts-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
src/content.config.ts
Normal file
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>
|
||||||
128
src/layouts/PostLayout.astro
Normal file
128
src/layouts/PostLayout.astro
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from './BaseLayout.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pubDate: Date;
|
||||||
|
tags?: string[];
|
||||||
|
day?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, pubDate, tags = [], day } = Astro.props;
|
||||||
|
|
||||||
|
const formatted = pubDate.toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description}>
|
||||||
|
<article class="post-wrapper">
|
||||||
|
<div class="post-hero container">
|
||||||
|
{day && (
|
||||||
|
<div class="day-badge font-mono">
|
||||||
|
<span class="text-muted">entry_</span>{String(day).padStart(3, '0')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 class="post-title">{title}</h1>
|
||||||
|
<p class="post-description text-muted">{description}</p>
|
||||||
|
<div class="post-meta">
|
||||||
|
<time class="font-mono text-muted" datetime={pubDate.toISOString()}>{formatted}</time>
|
||||||
|
<div class="post-tags">
|
||||||
|
{tags.map(tag => <span class="tag">{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="glow-divider" />
|
||||||
|
|
||||||
|
<div class="post-body container">
|
||||||
|
<div class="prose">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<aside class="post-sidebar">
|
||||||
|
<div class="card sidebar-card">
|
||||||
|
<p class="font-display" style="font-size:0.8rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:1rem">On this page</p>
|
||||||
|
<div id="toc-placeholder" class="font-mono text-muted" style="font-size:0.78rem">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="card sidebar-card" style="margin-top:1rem">
|
||||||
|
<p class="font-display" style="font-size:0.8rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:0.75rem">Tags</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
|
{tags.map(tag => <span class="tag">{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a href="/blog" class="btn btn-primary" style="margin-top:1rem;width:100%;justify-content:center">← All posts</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-generate TOC from headings
|
||||||
|
const toc = document.getElementById('toc-placeholder');
|
||||||
|
if (toc) {
|
||||||
|
const headings = document.querySelectorAll('.prose h2, .prose h3');
|
||||||
|
if (headings.length === 0) {
|
||||||
|
toc.textContent = '—';
|
||||||
|
} else {
|
||||||
|
headings.forEach((h, i) => {
|
||||||
|
if (!h.id) h.id = `heading-${i}`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `#${h.id}`;
|
||||||
|
a.textContent = (h.tagName === 'H3' ? ' · ' : '') + h.textContent;
|
||||||
|
a.style.cssText = 'display:block;color:var(--text-muted);margin-bottom:0.4rem;font-size:0.75rem;transition:color 150ms';
|
||||||
|
a.addEventListener('mouseenter', () => a.style.color = 'var(--petronas-teal)');
|
||||||
|
a.addEventListener('mouseleave', () => a.style.color = 'var(--text-muted)');
|
||||||
|
toc.appendChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-wrapper { padding-top: 8rem; padding-bottom: 4rem; }
|
||||||
|
|
||||||
|
.post-hero { max-width: 900px; }
|
||||||
|
|
||||||
|
.day-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
max-width: 65ch;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.post-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 260px;
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-card { position: sticky; top: 6rem; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.post-body { grid-template-columns: 1fr; }
|
||||||
|
.post-sidebar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/pages/404.astro
Normal file
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>
|
||||||
131
src/pages/blog/index.astro
Normal file
131
src/pages/blog/index.astro
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
const allPosts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||||
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||||
|
|
||||||
|
// Group by tags for sidebar
|
||||||
|
const allTags = [...new Set(allPosts.flatMap(p => p.data.tags ?? []))].sort();
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="Blog" description="Sysadmin chronicles — HPC, homelab, and everything in between">
|
||||||
|
<div class="blog-page">
|
||||||
|
<div class="blog-hero container">
|
||||||
|
<div class="accent-line"></div>
|
||||||
|
<h1>The <span class="text-accent">Log</span></h1>
|
||||||
|
<p class="text-muted font-mono" style="font-size:0.9rem;margin-top:0.5rem">
|
||||||
|
{allPosts.length} entries · HPC · homelab · infra · ops
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blog-layout container">
|
||||||
|
<!-- Post list -->
|
||||||
|
<div class="post-list">
|
||||||
|
{allPosts.map((post, i) => (
|
||||||
|
<article class="post-row card">
|
||||||
|
<a href={`/blog/${post.id}`} class="post-row-link">
|
||||||
|
<div class="post-row-left">
|
||||||
|
{post.data.day && (
|
||||||
|
<div class="font-mono" style="font-size:0.7rem;color:var(--petronas-teal);letter-spacing:0.1em;margin-bottom:0.3rem">
|
||||||
|
entry_{String(post.data.day).padStart(3,'0')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 class="post-row-title">{post.data.title}</h2>
|
||||||
|
<p class="text-muted post-row-desc">{post.data.description}</p>
|
||||||
|
<div class="post-row-meta">
|
||||||
|
<time class="font-mono text-muted" style="font-size:0.72rem">
|
||||||
|
{post.data.pubDate.toLocaleDateString('en-GB', {day:'numeric',month:'short',year:'numeric'})}
|
||||||
|
</time>
|
||||||
|
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
|
||||||
|
{(post.data.tags ?? []).map((t: string) => <span class="tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-row-arrow text-accent font-display">→</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="blog-sidebar">
|
||||||
|
<div class="card" style="position:sticky;top:5.5rem">
|
||||||
|
<p class="font-display" style="font-size:0.75rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-muted);margin-bottom:1rem">Tags</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
|
{allTags.map(t => <span class="tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
<hr class="glow-divider" style="margin:1.5rem 0" />
|
||||||
|
<p class="font-display" style="font-size:0.75rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-muted);margin-bottom:0.75rem">Navigate</p>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.35rem">
|
||||||
|
<a href="/" class="text-muted font-mono" style="font-size:0.8rem">← Home</a>
|
||||||
|
<a href="/homelab" class="text-muted font-mono" style="font-size:0.8rem">Homelab status</a>
|
||||||
|
<a href="/hardware" class="text-muted font-mono" style="font-size:0.8rem">Hardware list</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.blog-page { padding-top: 8rem; }
|
||||||
|
|
||||||
|
.blog-hero { margin-bottom: 3.5rem; }
|
||||||
|
|
||||||
|
.blog-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 240px;
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.post-row { padding: 0; }
|
||||||
|
|
||||||
|
.post-row-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: inherit;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-row:hover .post-row-title { color: var(--petronas-teal); }
|
||||||
|
|
||||||
|
.post-row-left { flex: 1; }
|
||||||
|
|
||||||
|
.post-row-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-row-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-row-arrow {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.post-row:hover .post-row-arrow { opacity: 1; transform: translateX(4px); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.blog-layout { grid-template-columns: 1fr; }
|
||||||
|
.blog-sidebar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
331
src/pages/hardware.astro
Normal file
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>
|
||||||
292
src/styles/global.css
Normal file
292
src/styles/global.css
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/* ============================================================
|
||||||
|
GLOBAL STYLES — AMG Petronas Green Theme
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Inter:wght@300;400;500&display=swap");
|
||||||
|
|
||||||
|
/* ── CSS Variables ─────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--petronas-teal: #00d2be;
|
||||||
|
--petronas-green: #00a19c;
|
||||||
|
--petronas-dark: #007a76;
|
||||||
|
--petronas-glow: rgba(0, 210, 190, 0.15);
|
||||||
|
--petronas-glow-lg: rgba(0, 210, 190, 0.35);
|
||||||
|
|
||||||
|
--bg-void: #050a0a;
|
||||||
|
--bg-deep: #080f0e;
|
||||||
|
--bg-surface: #0d1918;
|
||||||
|
--bg-card: #111e1d;
|
||||||
|
--bg-card-hover: #162524;
|
||||||
|
--bg-glass: rgba(13, 25, 24, 0.7);
|
||||||
|
|
||||||
|
--text-primary: #e8f5f4;
|
||||||
|
--text-secondary: #8bbab8;
|
||||||
|
--text-muted: #4a7a78;
|
||||||
|
--text-accent: var(--petronas-teal);
|
||||||
|
|
||||||
|
--border-subtle: rgba(0, 210, 190, 0.08);
|
||||||
|
--border-glow: rgba(0, 210, 190, 0.35);
|
||||||
|
|
||||||
|
--font-display: "Rajdhani", sans-serif;
|
||||||
|
--font-mono: "DM Mono", monospace;
|
||||||
|
--font-body: "Inter", sans-serif;
|
||||||
|
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-med: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ─────────────────────────────────────────────────── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ─────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--petronas-dark);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--petronas-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ────────────────────────────────────────────── */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-left: 3px solid var(--petronas-teal);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ────────────────────────────────────────────────── */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Teal Accent Line ──────────────────────────────────────── */
|
||||||
|
.accent-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--petronas-teal), transparent);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ─────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-med),
|
||||||
|
background var(--transition-med),
|
||||||
|
transform var(--transition-med);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glow Button ───────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-med);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
border: 1px solid var(--petronas-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--petronas-glow);
|
||||||
|
box-shadow: 0 0 24px var(--petronas-glow-lg);
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tag / Badge ───────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
background: var(--petronas-glow);
|
||||||
|
border: 1px solid rgba(0, 210, 190, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glow Divider ──────────────────────────────────────────── */
|
||||||
|
.glow-divider {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--petronas-teal),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
margin: 4rem 0;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Noise texture overlay ─────────────────────────────────── */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0.025;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility ───────────────────────────────────────────────── */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.text-accent {
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
}
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prose (blog posts) ────────────────────────────────────── */
|
||||||
|
.prose {
|
||||||
|
max-width: 72ch;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.prose h2,
|
||||||
|
.prose h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
.prose ul,
|
||||||
|
.prose ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
.prose li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 3px solid var(--petronas-teal);
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
.prose img {
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.prose a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.prose strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.prose code {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--petronas-teal);
|
||||||
|
}
|
||||||
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user