adding demos

This commit is contained in:
juvdiaz 2026-05-24 14:59:08 -06:00
parent c7263f4673
commit aca71f7cd3
44 changed files with 2831 additions and 79 deletions

2
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.tfstate
*.tfstate.backup
.terraform/
.lab/
# Ignore local archive dumps and backups
*.tar
@ -10,4 +11,3 @@ apps/gitea/gitea-docker-backup
# Ignore older source iterations
*.old
*.terraform.lock.hcl

166
README.md
View File

@ -3,6 +3,23 @@
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
Argo CD.
## Architecture
The lab is intentionally small but production-shaped:
- a Debian amd64 host runs the kubeadm control plane and local deployment tools
- a Raspberry Pi arm64 node runs selected workloads
- OpenTofu owns the bootstrap layers for cluster, platform, apps, and edge
- Argo CD continuously reconciles Kubernetes manifests from this repo
- a local registry stores the website and demos images built for the worker
architecture
- an OCI jump box provides the public edge path back into the homelab over
Tailscale
Run `./lab.sh up` and `./lab.sh nuke` only from the Debian homelab server. The
script intentionally refuses to run from non-Debian machines so a laptop cannot
accidentally modify the cluster.
## Flow
1. `bootstrap/cluster`
@ -22,8 +39,8 @@ Argo CD.
3. `bootstrap/apps`
- registers Argo CD Applications from the `applications` map
- default apps are `container-registry`, `gitea`, and
`website-production`
- default apps are `container-registry`, `gitea`, `website-production`, and
`demos-static`
4. `bootstrap/edge`
- connects to the OCI jump box
@ -31,6 +48,64 @@ Argo CD.
- obtains and renews Let's Encrypt certificates for the configured hostname
- runs the edge cache/proxy chain with Docker Compose
## Prerequisites
On the Debian host:
- OpenTofu
- Docker with Buildx
- kubeadm, kubelet, kubectl, and containerd
- SSH access to worker nodes
- SSH access to the OCI edge host
- enough persistent storage for `/var/openebs/local` and `/var/lib/docker`
The default kubeconfig path is `/home/jv/.kube/config`. Override it with
`KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed.
## Deploying
From the Debian server:
```bash
cd ~/my-homelab-configs
./lab.sh up
```
The script applies the OpenTofu stacks in order, refreshes Argo CD apps, waits
for the local registry, builds the website and demos images when their source
changed, pushes them to the registry, recreates pods only after a new image is
built, and then applies the edge stack.
The website and demos images default to `linux/arm64` because both deployments
are pinned to the Raspberry Pi worker. Override with `WEBSITE_IMAGE_PLATFORMS`
or `DEMOS_IMAGE_PLATFORMS` only if node placement changes.
Build metadata is written under `.lab/` so repeat runs can skip the website
or demos image build when the source hash, platform, image reference, and
registry manifest still match.
## Validation
Useful checks after a rebuild:
```bash
export KUBECONFIG=/home/jv/.kube/config
kubectl get nodes
kubectl -n argocd get applications
kubectl -n container-registry get pods
kubectl -n gitea-system get pods
kubectl -n website-production get pods -o wide
kubectl -n demos-static get pods -o wide
docker info --format '{{.DockerRootDir}}'
df -h / /var/openebs/local /var/lib/docker
```
The website should be reached through the configured public hostname, not the raw
OCI IP address, because the Let's Encrypt certificate is issued for the
hostname.
## Adding Nodes
Add entries to `bootstrap/cluster/variables.tf` or a `.tfvars` file:
@ -49,12 +124,13 @@ worker_nodes = {
Stateful apps currently pin retained local PVs to the `debian` node. Move or
duplicate those PV manifests when you want storage on another node.
The website NodePort is reachable from the OCI jump box through the Raspberry Pi
Tailscale interface. `bootstrap/cluster` installs a persistent
The website and demos NodePorts are reachable from the OCI jump box through the
Raspberry Pi Tailscale interface. `bootstrap/cluster` installs a persistent
`homelab-tailscale-nodeport.service` on the configured worker to restore the
route, rp_filter settings, and iptables rules after reboot. Override the
defaults through `tailscale_nodeport_access` when the jump-box IP, Pi Tailscale
IP, pod CIDR, or NodePort changes:
IP, pod CIDR, primary NodePort, or pod target port changes. Add any additional
public NodePorts to `tailscale_nodeport_extra_ports`:
```hcl
tailscale_nodeport_access = {
@ -66,6 +142,8 @@ tailscale_nodeport_access = {
node_port = 30080
target_port = 80
}
tailscale_nodeport_extra_ports = [30081]
```
For `./lab.sh nuke`, set `WORKER_SSH_TARGETS` to a space-separated list of
@ -96,7 +174,7 @@ certificate warning because the trusted certificate is issued for the hostname.
The edge stack uses HTTP-01 validation, so public DNS for `server_name` must
point to the OCI public IP and inbound TCP 80 and 443 must be open before
`./lab.sh deploy` runs. Set `TF_VAR_letsencrypt_email` to receive expiry notices,
`./lab.sh up` runs. Set `TF_VAR_letsencrypt_email` to receive expiry notices,
or leave it empty to register without an email. Set
`TF_VAR_enable_letsencrypt=false` to keep using the temporary local certificate.
@ -115,5 +193,81 @@ reset paths so data can survive cluster destroy/create cycles. Those critical
volumes are declared explicitly as retained local PVs so a rebuilt cluster binds
back to the same host paths instead of creating fresh directories.
For the current lab, `/var/openebs/local` and `/var/lib/docker` are expected to
live on larger storage than the root filesystem. This keeps retained PVs,
container layers, Buildx state, and image caches from filling `/`.
## Destructive Rebuilds
`./lab.sh nuke` resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, local OpenTofu state, and configured worker nodes. It does
not delete retained data under `/var/openebs/local`.
For multi-node labs, set `WORKER_SSH_TARGETS` to a space-separated list of SSH
targets. For a single-node rebuild, set it to an empty string.
## Website App
The website is a PHP app under `apps/website`. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Ollama.
Static language files live in `apps/website/lang`; unsupported browser languages
can be translated by the client and saved through `save_lang.php`.
The CV page has two client-side presentation modes:
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile
image and light green console text.
- `Fancy`: centered circular profile image, cursive orbit text, and a
cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate `demos-static` artifact under `apps/demos-static` and are
published through the `demos-static` Argo CD application. Public traffic reaches
them through the edge path at `/demo-apps/`.
`./lab.sh up` builds and pushes two independent images:
- `php-website:latest` from `apps/website`
- `demos-static:latest` from `apps/demos-static`
The first demo, `The Client-Side Media Cruncher (Wasm + TS)`, currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from the Raspberry Pi worker without turning either pod into an application
server. The website pod serves the portfolio shell and the `demos-static` pod
serves static demo bundles; CPU-heavy work runs in the visitor's browser. With
the current deployments pinned to the Raspberry Pi, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future
Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and
stability using same-origin browser probes; a dedicated WebSocket echo endpoint
would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and
local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB
conversions.
- Privacy-first redactor: local image redaction prototype; future
onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary
prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs,
and retraining.
The Kubernetes deployment uses `apps/website/web-app.yaml`. Keep the image
reference there aligned with `TF_VAR_registry_endpoint`, because `lab.sh` derives
the registry endpoint from that manifest.
Keep the `.terraform.lock.hcl` files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.

View File

@ -0,0 +1,6 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
EXPOSE 80

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: demos-static

View File

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
return 204;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(css|js|webmanifest)$ {
add_header Cache-Control "public, max-age=3600";
try_files $uri =404;
}
location ~* \.(wasm|onnx|bin)$ {
add_header Cache-Control "public, immutable, max-age=604800";
try_files $uri =404;
}
}

View File

@ -0,0 +1,65 @@
const canvas = document.getElementById('arch-canvas');
const ctx = canvas.getContext('2d');
const state = {
users: 120,
databaseLoad: 45,
servers: [
{ name: 'web-1', healthy: true, load: 34 },
{ name: 'web-2', healthy: true, load: 28 },
{ name: 'web-3', healthy: true, load: 31 },
],
};
function box(x, y, width, height, fill, stroke, label) {
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.beginPath();
ctx.roundRect(x, y, width, height, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#102a43';
ctx.fillText(label, x + 12, y + height / 2 + 5);
}
function draw() {
const healthy = state.servers.filter((server) => server.healthy);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = '14px Arial';
ctx.fillStyle = '#102a43';
ctx.fillText(`${state.users} simulated users`, 30, 36);
box(240, 95, 110, 64, '#dbeafe', '#2563eb', 'Load balancer');
state.servers.forEach((server, index) => {
const x = 440;
const y = 40 + index * 68;
server.load = server.healthy && healthy.length ? Math.min(100, Math.round(state.users / healthy.length / 6)) : 0;
ctx.strokeStyle = '#9fb3c8';
ctx.beginPath();
ctx.moveTo(350, 127);
ctx.lineTo(x, y + 24);
ctx.stroke();
box(x, y, 120, 48, server.healthy ? '#e8fff1' : '#ffe8e8', server.healthy ? '#2f855a' : '#c53030', `${server.name} ${server.healthy ? server.load + '%' : 'down'}`);
});
state.databaseLoad = Math.min(100, state.databaseLoad + state.users / 400);
ctx.strokeStyle = '#9fb3c8';
ctx.beginPath();
ctx.moveTo(560, 127);
ctx.lineTo(620, 127);
ctx.stroke();
box(620, 95, 72, 64, state.databaseLoad > 80 ? '#fff5d6' : '#edf2f7', state.databaseLoad > 80 ? '#d69e2e' : '#52606d', `DB ${Math.round(state.databaseLoad)}%`);
document.getElementById('arch-status').textContent = healthy.length ? `Traffic split across ${healthy.length} healthy web nodes.` : 'All web nodes are down. Auto-scaling is needed.';
}
document.getElementById('add-users').addEventListener('click', () => { state.users += 220; state.databaseLoad += 10; draw(); });
document.getElementById('server-crash').addEventListener('click', () => {
const server = state.servers.find((item) => item.healthy);
if (server) server.healthy = false;
draw();
});
document.getElementById('auto-scale').addEventListener('click', () => {
state.servers.push({ name: `web-${state.servers.length + 1}`, healthy: true, load: 10 });
state.databaseLoad = Math.max(35, state.databaseLoad - 20);
draw();
});
draw();

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Architecture Simulator</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 04</p><h1>Interactive System Architecture Simulator</h1><p>Click-driven load, failure, and self-healing simulation for a tiny web stack.</p></section>
<section class="panel">
<canvas id="arch-canvas" width="720" height="260"></canvas>
<div class="actions"><button id="add-users" type="button">Add Users</button><button id="server-crash" type="button">Server Crash</button><button id="auto-scale" type="button">Auto-scale</button></div>
<p id="arch-status" class="note"></p>
</section>
</main>
<script src="architecture-simulator.js"></script>
</body>
</html>

View File

@ -0,0 +1,49 @@
const input = document.getElementById('tool-input');
const output = document.getElementById('tool-output');
const logFilter = document.getElementById('log-filter');
function setOutput(value) {
output.textContent = value;
}
function decodeBase64Url(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
return decodeURIComponent(atob(padded).split('').map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''));
}
document.getElementById('format-json').addEventListener('click', () => {
try {
setOutput(JSON.stringify(JSON.parse(input.value), null, 2));
} catch (error) {
setOutput(`JSON parse error: ${error.message}`);
}
});
document.getElementById('decode-jwt').addEventListener('click', () => {
try {
const parts = input.value.trim().split('.');
if (parts.length < 2) throw new Error('Expected header.payload.signature');
setOutput(JSON.stringify({ header: JSON.parse(decodeBase64Url(parts[0])), payload: JSON.parse(decodeBase64Url(parts[1])) }, null, 2));
} catch (error) {
setOutput(`JWT decode error: ${error.message}`);
}
});
document.getElementById('parse-url').addEventListener('click', () => {
try {
const url = new URL(input.value.trim());
const params = {};
url.searchParams.forEach((value, key) => { params[key] = value; });
setOutput(JSON.stringify({ protocol: url.protocol, host: url.host, pathname: url.pathname, query: params, hash: url.hash }, null, 2));
} catch (error) {
setOutput(`URL parse error: ${error.message}`);
}
});
document.getElementById('filter-logs').addEventListener('click', () => {
const needle = logFilter.value.trim().toLowerCase();
const lines = input.value.split('\n');
const result = needle ? lines.filter((line) => line.toLowerCase().includes(needle)) : lines;
setOutput(result.join('\n') || 'No matching lines.');
});

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Local Log and JSON Toolbelt</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 03</p><h1>Local Log and JSON Toolbelt</h1><p>Format JSON, decode JWT payloads, parse URLs, and filter logs without sending data to a backend.</p></section>
<section class="panel">
<textarea id="tool-input" spellcheck="false" placeholder='{"status":"messy","items":[1,2,3]}'></textarea>
<div class="actions"><button id="format-json" type="button">Format JSON</button><button id="decode-jwt" type="button">Decode JWT</button><button id="parse-url" type="button">Parse URL</button></div>
<div class="controls"><input id="log-filter" type="search" placeholder="keyword, timestamp, error code..."><button id="filter-logs" type="button">Filter logs</button></div>
<pre id="tool-output"></pre>
</section>
</main>
<script src="dev-toolbelt.js"></script>
</body>
</html>

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Homelab Demo Artifacts</title>
<link rel="stylesheet" href="shared.css">
</head>
<body>
<main class="shell">
<nav class="topline">
<a href="/demos.php">Back to website</a>
<a href="/demo-apps/">Demo artifacts</a>
</nav>
<section class="hero">
<p class="kicker">Separate artifacts</p>
<h1>Homelab Demo Apps</h1>
<p>Each demo is served from the dedicated demos-static image instead of the PHP website image.</p>
</section>
<section class="grid">
<a class="catalog-card" href="media-cruncher/"><span>Demo 01</span><h2>Media Cruncher</h2><p>Local image compression and conversion.</p></a>
<a class="catalog-card" href="network-quality/"><span>Demo 02</span><h2>Internet Quality</h2><p>Latency, jitter, and stability graph.</p></a>
<a class="catalog-card" href="dev-toolbelt/"><span>Demo 03</span><h2>Log and JSON Toolbelt</h2><p>JSON, JWT, URL, and log utilities.</p></a>
<a class="catalog-card" href="architecture-simulator/"><span>Demo 04</span><h2>Architecture Simulator</h2><p>Load, failure, and self-healing canvas.</p></a>
<a class="catalog-card" href="traveler-tools/"><span>Demo 05</span><h2>Traveler Converter</h2><p>Offline-capable timezone and unit tools.</p></a>
<a class="catalog-card" href="privacy-redactor/"><span>Demo 06</span><h2>Privacy Redactor</h2><p>Local image redaction prototype.</p></a>
<a class="catalog-card" href="sentiment-sandbox/"><span>Demo 07</span><h2>Sentiment Sandbox</h2><p>Local-first text analytics prototype.</p></a>
<a class="catalog-card" href="model-drift/"><span>Demo 08</span><h2>Model Drift Simulator</h2><p>MLOps drift and retraining playground.</p></a>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Client-Side Media Cruncher</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero">
<p class="kicker">Demo 01</p>
<h1>The Client-Side Media Cruncher</h1>
<p>Drop in a large image and convert or compress it locally. The browser does the work; the server sees nothing.</p>
</section>
<section class="panel">
<label class="drop-zone" for="media-input" id="drop-zone">
<strong>Drop images here</strong>
<span>WebP, JPEG, and PNG output. Video is a future Rust/Wasm codec target.</span>
</label>
<input id="media-input" type="file" accept="image/*,video/*" multiple>
<div class="controls">
<label>Output <select id="output-format"><option value="image/webp">WebP</option><option value="image/jpeg">JPEG</option><option value="image/png">PNG</option></select></label>
<label>Quality <input id="quality" type="range" min="35" max="95" value="78"><output id="quality-output">78%</output></label>
<label>Max edge <input id="max-edge" type="number" min="320" max="8000" step="160" value="1920"></label>
</div>
<div id="cruncher-results"><p class="note">Drop a file to see local compression results.</p></div>
</section>
</main>
<script src="media-cruncher.js"></script>
</body>
</html>

View File

@ -0,0 +1,107 @@
const mediaInput = document.getElementById('media-input');
const dropZone = document.getElementById('drop-zone');
const results = document.getElementById('cruncher-results');
const outputFormat = document.getElementById('output-format');
const quality = document.getElementById('quality');
const qualityOutput = document.getElementById('quality-output');
const maxEdge = document.getElementById('max-edge');
const extensionByType = { 'image/webp': 'webp', 'image/jpeg': 'jpg', 'image/png': 'png' };
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function toBlob(canvas, type, imageQuality) {
if ('convertToBlob' in canvas) return canvas.convertToBlob({ type, quality: imageQuality });
return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Could not encode image')), type, imageQuality));
}
function createCanvas(width, height) {
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
function appendStatus(name, message) {
const row = document.createElement('div');
row.className = 'result-row';
const content = document.createElement('div');
const title = document.createElement('strong');
const detail = document.createElement('span');
title.textContent = name;
detail.textContent = message;
content.append(title, detail);
row.append(content);
results.append(row);
}
async function crunchImage(file) {
const bitmap = await createImageBitmap(file);
const originalWidth = bitmap.width;
const originalHeight = bitmap.height;
const maxSize = Math.max(320, Number(maxEdge.value) || 1920);
const scale = Math.min(1, maxSize / Math.max(originalWidth, originalHeight));
const width = Math.max(1, Math.round(originalWidth * scale));
const height = Math.max(1, Math.round(originalHeight * scale));
const canvas = createCanvas(width, height);
const context = canvas.getContext('2d', { alpha: outputFormat.value !== 'image/jpeg' });
context.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const type = outputFormat.value;
const imageQuality = type === 'image/png' ? undefined : Number(quality.value) / 100;
const blob = await toBlob(canvas, type, imageQuality);
const url = URL.createObjectURL(blob);
const saved = file.size - blob.size;
const ratio = file.size > 0 ? Math.round((saved / file.size) * 100) : 0;
const row = document.createElement('div');
const content = document.createElement('div');
const name = document.createElement('strong');
const dimensions = document.createElement('span');
const sizes = document.createElement('span');
const download = document.createElement('a');
row.className = 'result-row';
name.textContent = file.name;
dimensions.textContent = `${originalWidth}x${originalHeight} -> ${width}x${height}`;
sizes.textContent = `${formatBytes(file.size)} -> ${formatBytes(blob.size)} (${saved >= 0 ? ratio + '% smaller' : Math.abs(ratio) + '% larger'})`;
download.className = 'download-link';
download.download = `${file.name.replace(/\.[^.]+$/, '')}-crunched.${extensionByType[type] || 'bin'}`;
download.href = url;
download.textContent = 'Download';
content.append(name, dimensions, sizes);
row.append(content, download);
results.append(row);
}
async function handleFiles(files) {
results.innerHTML = '';
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await crunchImage(file);
} catch (error) {
appendStatus(file.name, error.message);
}
} else if (file.type.startsWith('video/')) {
appendStatus(file.name, 'Video accepted as a future Wasm codec target. Nothing was uploaded.');
} else {
appendStatus(file.name, 'Unsupported file type.');
}
}
}
quality.addEventListener('input', () => { qualityOutput.textContent = `${quality.value}%`; });
mediaInput.addEventListener('change', () => handleFiles(mediaInput.files));
['dragenter', 'dragover'].forEach((eventName) => dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach((eventName) => dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove('is-dragging');
}));
dropZone.addEventListener('drop', (event) => handleFiles(event.dataTransfer.files));

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Model Drift Simulator</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 08</p><h1>Model Drift and Performance Simulator</h1><p>MLOps playground: create drift, corrupt inputs, and retrain back to a healthier curve.</p></section>
<section class="panel">
<canvas id="drift-canvas" width="720" height="220"></canvas>
<div class="actions"><button id="drift-spike" type="button">Black Friday Spike</button><button id="drift-corrupt" type="button">Corrupt Inputs</button><button id="drift-retrain" type="button">Retrain Model</button></div>
<p id="drift-status" class="note"></p>
</section>
</main>
<script src="model-drift.js"></script>
</body>
</html>

View File

@ -0,0 +1,52 @@
const canvas = document.getElementById('drift-canvas');
const ctx = canvas.getContext('2d');
let accuracy = Array.from({ length: 44 }, (_, index) => 92 - Math.sin(index / 3) * 2);
let status = 'Model is healthy.';
function draw() {
const width = canvas.width;
const height = canvas.height;
const pad = 28;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = '#d9e2ec';
for (let i = 0; i < 5; i += 1) {
const y = pad + ((height - pad * 2) / 4) * i;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
}
ctx.strokeStyle = '#805ad5';
ctx.lineWidth = 3;
ctx.beginPath();
accuracy.forEach((value, index) => {
const x = pad + (index / Math.max(1, accuracy.length - 1)) * (width - pad * 2);
const y = height - pad - ((value - 40) / 60) * (height - pad * 2);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
document.getElementById('drift-status').textContent = status;
}
document.getElementById('drift-spike').addEventListener('click', () => {
accuracy = accuracy.map((value, index) => index > 20 ? Math.max(55, value - (index - 18) * 0.9) : value);
status = 'Black Friday traffic spike: feature distribution shifted, accuracy is sliding.';
draw();
});
document.getElementById('drift-corrupt').addEventListener('click', () => {
accuracy = accuracy.map((value, index) => index % 4 === 0 ? Math.max(45, value - 18) : value);
status = 'Corrupted input data: model confidence is having a very bad afternoon.';
draw();
});
document.getElementById('drift-retrain').addEventListener('click', () => {
accuracy = accuracy.map((_, index) => 91 + Math.sin(index / 4) * 3);
status = 'Retrained model: accuracy recovered and everyone pretends this was planned.';
draw();
});
draw();

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>How Is My Internet, Really?</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 02</p><h1>How Is My Internet, Really?</h1><p>Latency, jitter, and stability on a live Canvas graph. This version uses same-origin probes; a WebSocket echo endpoint is the production-grade path.</p></section>
<section class="panel">
<canvas id="network-canvas" width="720" height="220"></canvas>
<div class="actions"><button id="network-start" type="button">Start</button><button id="network-stop" type="button">Stop</button></div>
<div class="stats"><span>Latency <strong id="latency-stat">--</strong></span><span>Jitter <strong id="jitter-stat">--</strong></span><span>Stability <strong id="stability-stat">--</strong></span></div>
</section>
</main>
<script src="network-quality.js"></script>
</body>
</html>

View File

@ -0,0 +1,59 @@
const canvas = document.getElementById('network-canvas');
const ctx = canvas.getContext('2d');
const samples = [];
let timer = null;
function draw(values) {
const width = canvas.width;
const height = canvas.height;
const pad = 28;
const max = Math.max(120, ...values);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = '#d9e2ec';
for (let i = 0; i < 5; i += 1) {
const y = pad + ((height - pad * 2) / 4) * i;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
}
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 3;
ctx.beginPath();
values.forEach((value, index) => {
const x = pad + (index / Math.max(1, values.length - 1)) * (width - pad * 2);
const y = height - pad - (value / max) * (height - pad * 2);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
async function measure() {
const start = performance.now();
await fetch(`../shared.css?probe=${Date.now()}`, { cache: 'no-store' });
const latency = Math.round(performance.now() - start);
samples.push(latency);
while (samples.length > 60) samples.shift();
const deltas = samples.slice(1).map((value, index) => Math.abs(value - samples[index]));
const jitter = deltas.length ? Math.round(deltas.reduce((sum, value) => sum + value, 0) / deltas.length) : 0;
const average = Math.round(samples.reduce((sum, value) => sum + value, 0) / samples.length);
const stability = Math.max(0, Math.min(100, Math.round(100 - (jitter / Math.max(average, 1)) * 100)));
document.getElementById('latency-stat').textContent = `${latency} ms`;
document.getElementById('jitter-stat').textContent = `${jitter} ms`;
document.getElementById('stability-stat').textContent = `${stability}%`;
draw(samples);
}
document.getElementById('network-start').addEventListener('click', () => {
if (timer) return;
measure().catch(() => {});
timer = window.setInterval(() => measure().catch(() => {}), 1400);
});
document.getElementById('network-stop').addEventListener('click', () => {
window.clearInterval(timer);
timer = null;
});
draw([0, 0]);

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Privacy-First Object Redactor</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 06</p><h1>Privacy-First Object Redactor</h1><p>Drop an image, blur sensitive regions locally, and download the result. The future production path is onnxruntime-web plus a quantized detector in a worker.</p></section>
<section class="panel">
<label class="drop-zone" for="redactor-input">Choose an image to redact locally</label>
<input id="redactor-input" type="file" accept="image/*">
<canvas id="redactor-canvas" width="720" height="260"></canvas>
<div class="actions"><button id="auto-redact" type="button">Demo Auto-Blur</button><button id="download-redacted" type="button">Download</button></div>
</section>
</main>
<script src="privacy-redactor.js"></script>
</body>
</html>

View File

@ -0,0 +1,54 @@
const canvas = document.getElementById('redactor-canvas');
const ctx = canvas.getContext('2d');
let imageBounds = null;
function placeholder() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#52606d';
ctx.font = '18px Arial';
ctx.fillText('Choose an image to redact it locally.', 230, 132);
}
function blurRect(x, y, width, height) {
ctx.save();
ctx.filter = 'blur(14px)';
ctx.drawImage(canvas, x, y, width, height, x, y, width, height);
ctx.restore();
ctx.fillStyle = 'rgba(47, 133, 90, 0.18)';
ctx.fillRect(x, y, width, height);
}
document.getElementById('redactor-input').addEventListener('change', (event) => {
const [file] = event.target.files;
if (!file) return;
const image = new Image();
image.onload = () => {
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
const width = image.width * scale;
const height = image.height * scale;
const x = (canvas.width - width) / 2;
const y = (canvas.height - height) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, x, y, width, height);
imageBounds = { x, y, width, height };
URL.revokeObjectURL(image.src);
};
image.src = URL.createObjectURL(file);
});
document.getElementById('auto-redact').addEventListener('click', () => {
const bounds = imageBounds || { x: 150, y: 45, width: 420, height: 170 };
blurRect(bounds.x + bounds.width * 0.33, bounds.y + bounds.height * 0.16, bounds.width * 0.28, bounds.height * 0.32);
blurRect(bounds.x + bounds.width * 0.12, bounds.y + bounds.height * 0.68, bounds.width * 0.42, bounds.height * 0.16);
});
document.getElementById('download-redacted').addEventListener('click', () => {
const link = document.createElement('a');
link.download = 'redacted-image.png';
link.href = canvas.toDataURL('image/png');
link.click();
});
placeholder();

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Local Sentiment Sandbox</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 07</p><h1>Local Sentiment and Text Analytics</h1><p>Prototype local sentiment, keywords, and summary. Production path: Transformers.js with a small ONNX model.</p></section>
<section class="panel">
<textarea id="sentiment-input">The deployment was fast and smooth, but the storage issue was terrible until Docker moved to the external SSD.</textarea>
<div class="actions"><button id="analyze-text" type="button">Analyze text</button></div>
<div id="sentiment-output" class="output"></div>
</section>
</main>
<script src="sentiment-sandbox.js"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
const input = document.getElementById('sentiment-input');
const output = document.getElementById('sentiment-output');
const positive = ['great', 'fast', 'love', 'excellent', 'happy', 'reliable', 'smooth', 'good'];
const negative = ['bad', 'slow', 'hate', 'broken', 'angry', 'fail', 'error', 'terrible'];
function analyze() {
const text = input.value.toLowerCase();
const words = text.match(/[a-zA-Z]{4,}/g) || [];
const score = positive.reduce((sum, word) => sum + (text.includes(word) ? 1 : 0), 0)
- negative.reduce((sum, word) => sum + (text.includes(word) ? 1 : 0), 0);
const counts = new Map();
words.forEach((word) => counts.set(word, (counts.get(word) || 0) + 1));
const keywords = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([word]) => word);
const summary = input.value.split(/[.!?]/).find((sentence) => sentence.trim().length > 20)?.trim() || 'Short text.';
output.textContent = [
`Sentiment: ${score > 0 ? 'Positive' : score < 0 ? 'Negative' : 'Neutral'}`,
`Score: ${score}`,
`Keywords: ${keywords.join(', ') || 'none'}`,
`Tiny summary: ${summary}`,
].join('\n');
}
document.getElementById('analyze-text').addEventListener('click', analyze);
analyze();

View File

@ -0,0 +1,255 @@
body {
margin: 0;
background: #f8fafc;
color: #102a43;
font-family: Arial, sans-serif;
}
a {
color: inherit;
}
.shell {
width: min(1100px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0 48px;
}
.topline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.topline a {
color: #004085;
font-weight: 800;
text-decoration: none;
}
.hero {
margin-bottom: 24px;
border-bottom: 1px solid #d9e2ec;
padding-bottom: 20px;
}
.kicker {
margin: 0 0 8px;
color: #2f855a;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.78rem;
}
h1 {
margin: 0;
color: #102a43;
font-size: 2.3rem;
}
.hero p,
.note {
max-width: 780px;
color: #52606d;
line-height: 1.6;
}
.panel {
background: #fff;
border: 1px solid #d9e2ec;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(16, 42, 67, 0.08);
padding: 22px;
margin-top: 18px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.catalog-card {
display: block;
min-height: 150px;
background: #fff;
border: 1px solid #d9e2ec;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(16, 42, 67, 0.08);
padding: 20px;
text-decoration: none;
}
.catalog-card span {
display: block;
color: #2f855a;
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.catalog-card h2 {
margin: 8px 0;
color: #102a43;
font-size: 1.2rem;
}
.catalog-card p {
color: #52606d;
line-height: 1.5;
}
.drop-zone {
display: grid;
place-items: center;
min-height: 130px;
padding: 20px;
border: 2px dashed #9fb3c8;
border-radius: 8px;
background: #f8fafc;
color: #102a43;
cursor: pointer;
text-align: center;
}
.drop-zone.is-dragging {
background: #e8fff1;
border-color: #2f855a;
}
input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin: 18px 0;
}
label {
display: grid;
gap: 6px;
color: #102a43;
font-weight: 800;
}
select,
input,
textarea {
box-sizing: border-box;
border: 1px solid #bcccdc;
border-radius: 6px;
padding: 9px 10px;
font: inherit;
}
textarea {
width: 100%;
min-height: 160px;
resize: vertical;
font-family: "Courier New", monospace;
}
button,
.download-link {
border: 0;
border-radius: 6px;
background: #004085;
color: #fff;
cursor: pointer;
font-weight: 900;
padding: 9px 12px;
text-decoration: none;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 12px 0;
}
canvas {
width: 100%;
max-width: 100%;
height: auto;
border: 1px solid #d9e2ec;
border-radius: 8px;
background: #f8fafc;
}
.output,
pre {
min-height: 86px;
margin: 12px 0;
padding: 12px;
border-radius: 8px;
background: #102a43;
color: #d9f99d;
overflow: auto;
white-space: pre-wrap;
font-family: "Courier New", monospace;
}
.result-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 10px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
border-left: 4px solid #2f855a;
}
.result-row strong,
.result-row span {
display: block;
}
.result-row span {
color: #52606d;
font-size: 0.92rem;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.stats span {
display: grid;
gap: 4px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
color: #52606d;
}
.stats strong {
color: #102a43;
}
@media (max-width: 640px) {
.topline {
align-items: flex-start;
flex-direction: column;
}
.result-row {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Offline Traveler Converter</title>
<link rel="stylesheet" href="../shared.css">
<link rel="manifest" href="manifest.webmanifest">
</head>
<body>
<main class="shell">
<nav class="topline"><a href="../">All demos</a><a href="/demos.php">Website catalog</a></nav>
<section class="hero"><p class="kicker">Demo 05</p><h1>Offline Traveler Converter</h1><p>Timezone, currency, and GB/GiB conversion in an installable PWA shell.</p></section>
<section class="panel">
<div class="controls">
<label>Date and time <input id="traveler-time" type="datetime-local"></label>
<label>Target timezone <select id="traveler-zone"><option value="UTC">UTC</option><option value="America/Mexico_City">Mexico City</option><option value="America/New_York">New York</option><option value="Europe/London">London</option><option value="Europe/Madrid">Madrid</option><option value="Asia/Tokyo">Tokyo</option></select></label>
<label>USD amount <input id="traveler-amount" type="number" value="100" min="0" step="0.01"></label>
<label>GB amount <input id="traveler-gb" type="number" value="128" min="0" step="1"></label>
</div>
<div id="traveler-output" class="output"></div>
<p class="note">Currency rates are static demo values. The service worker caches the demo shell for offline use.</p>
</section>
</main>
<script src="traveler-tools.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
{
"name": "Offline Traveler Converter",
"short_name": "Traveler Tools",
"start_url": "./",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#004085",
"icons": []
}

View File

@ -0,0 +1,15 @@
const CACHE_NAME = 'traveler-tools-v1';
const ASSETS = ['./', 'index.html', 'traveler-tools.js', '../shared.css', 'manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
});
self.addEventListener('activate', (event) => {
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))));
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
});

View File

@ -0,0 +1,21 @@
function updateTraveler() {
const dateValue = document.getElementById('traveler-time').value;
const selectedZone = document.getElementById('traveler-zone').value;
const date = dateValue ? new Date(dateValue) : new Date();
const amount = Number(document.getElementById('traveler-amount').value) || 0;
const gb = Number(document.getElementById('traveler-gb').value) || 0;
const usdToMxn = 17.2;
const usdToEur = 0.92;
const gib = gb * (1000 ** 3) / (1024 ** 3);
document.getElementById('traveler-output').textContent = [
`Selected zone: ${new Intl.DateTimeFormat([], { dateStyle: 'medium', timeStyle: 'short', timeZone: selectedZone }).format(date)}`,
`UTC: ${new Intl.DateTimeFormat([], { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(date)}`,
`$${amount.toFixed(2)} USD ~= $${(amount * usdToMxn).toFixed(2)} MXN ~= EUR ${ (amount * usdToEur).toFixed(2)}`,
`${gb.toFixed(0)} GB ~= ${gib.toFixed(2)} GiB`,
].join('\n');
}
document.getElementById('traveler-time').value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
['traveler-time', 'traveler-zone', 'traveler-amount', 'traveler-gb'].forEach((id) => document.getElementById(id).addEventListener('input', updateTraveler));
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
updateTraveler();

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: demos-static
namespace: demos-static
labels:
app: demos-static
spec:
replicas: 1
selector:
matchLabels:
app: demos-static
template:
metadata:
labels:
app: demos-static
spec:
nodeSelector:
kubernetes.io/hostname: raspberry
containers:
- name: demos-static
image: 192.168.100.68:30500/demos-static:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 15m
memory: 32Mi
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: demos-static-service
namespace: demos-static
spec:
type: NodePort
externalTrafficPolicy: Local
ports:
- port: 80
targetPort: 80
nodePort: 30081
selector:
app: demos-static

289
apps/website/blog.php Normal file
View File

@ -0,0 +1,289 @@
<?php require_once __DIR__ . '/lang_helper.php'; ?>
<!DOCTYPE html>
<html lang="<?php echo $lang; ?>">
<head>
<meta charset="UTF-8">
<title><?php echo $text['blog_title']; ?> - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="top-nav">
<div class="nav-left">Juvenal Diaz</div>
<div class="nav-right">
<?php foreach ($availableLangs as $code): ?>
<a href="blog.php?lang=<?php echo $code; ?>"><?php echo strtoupper($code); ?></a>
<?php endforeach; ?>
|
<a href="index.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_home"
data-en="<?php echo htmlspecialchars($en['nav_home']); ?>">
<?php echo $text['nav_home']; ?>
</a>
<a href="cv.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_cv"
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
<?php echo $text['nav_cv']; ?>
</a>
<a href="blog.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_blog"
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
<?php echo $text['nav_blog']; ?>
</a>
<a href="demos.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_demos"
data-en="<?php echo htmlspecialchars($en['nav_demos']); ?>">
<?php echo $text['nav_demos']; ?>
</a>
</div>
</nav>
<main class="blog-page">
<section class="blog-hero">
<p class="blog-kicker"
data-translate data-key="blog_kicker"
data-en="<?php echo htmlspecialchars($en['blog_kicker']); ?>">
<?php echo $text['blog_kicker']; ?>
</p>
<h1 data-translate data-key="blog_title"
data-en="<?php echo htmlspecialchars($en['blog_title']); ?>">
<?php echo $text['blog_title']; ?>
</h1>
<p class="blog-subtitle"
data-translate data-key="blog_subtitle"
data-en="<?php echo htmlspecialchars($en['blog_subtitle']); ?>">
<?php echo $text['blog_subtitle']; ?>
</p>
</section>
<section class="conversation" aria-label="Homelab build conversation">
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q1"
data-en="<?php echo htmlspecialchars($en['blog_q1']); ?>">
<?php echo $text['blog_q1']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a1"
data-en="<?php echo htmlspecialchars($en['blog_a1']); ?>">
<?php echo $text['blog_a1']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q2"
data-en="<?php echo htmlspecialchars($en['blog_q2']); ?>">
<?php echo $text['blog_q2']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a2"
data-en="<?php echo htmlspecialchars($en['blog_a2']); ?>">
<?php echo $text['blog_a2']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q3"
data-en="<?php echo htmlspecialchars($en['blog_q3']); ?>">
<?php echo $text['blog_q3']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a3"
data-en="<?php echo htmlspecialchars($en['blog_a3']); ?>">
<?php echo $text['blog_a3']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q4"
data-en="<?php echo htmlspecialchars($en['blog_q4']); ?>">
<?php echo $text['blog_q4']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a4"
data-en="<?php echo htmlspecialchars($en['blog_a4']); ?>">
<?php echo $text['blog_a4']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q5"
data-en="<?php echo htmlspecialchars($en['blog_q5']); ?>">
<?php echo $text['blog_q5']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a5"
data-en="<?php echo htmlspecialchars($en['blog_a5']); ?>">
<?php echo $text['blog_a5']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q6"
data-en="<?php echo htmlspecialchars($en['blog_q6']); ?>">
<?php echo $text['blog_q6']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a6"
data-en="<?php echo htmlspecialchars($en['blog_a6']); ?>">
<?php echo $text['blog_a6']; ?>
</p>
</article>
<article class="message question">
<div class="speaker"
data-translate data-key="blog_speaker_question"
data-en="<?php echo htmlspecialchars($en['blog_speaker_question']); ?>">
<?php echo $text['blog_speaker_question']; ?>
</div>
<p data-translate data-key="blog_q7"
data-en="<?php echo htmlspecialchars($en['blog_q7']); ?>">
<?php echo $text['blog_q7']; ?>
</p>
</article>
<article class="message answer">
<div class="speaker"
data-translate data-key="blog_speaker_answer"
data-en="<?php echo htmlspecialchars($en['blog_speaker_answer']); ?>">
<?php echo $text['blog_speaker_answer']; ?>
</div>
<p data-translate data-key="blog_a7"
data-en="<?php echo htmlspecialchars($en['blog_a7']); ?>">
<?php echo $text['blog_a7']; ?>
</p>
</article>
</section>
<section class="tech-notes">
<h2 data-translate data-key="blog_stack_title"
data-en="<?php echo htmlspecialchars($en['blog_stack_title']); ?>">
<?php echo $text['blog_stack_title']; ?>
</h2>
<ul>
<li data-translate data-key="blog_stack_1"
data-en="<?php echo htmlspecialchars($en['blog_stack_1']); ?>">
<?php echo $text['blog_stack_1']; ?>
</li>
<li data-translate data-key="blog_stack_2"
data-en="<?php echo htmlspecialchars($en['blog_stack_2']); ?>">
<?php echo $text['blog_stack_2']; ?>
</li>
<li data-translate data-key="blog_stack_3"
data-en="<?php echo htmlspecialchars($en['blog_stack_3']); ?>">
<?php echo $text['blog_stack_3']; ?>
</li>
<li data-translate data-key="blog_stack_4"
data-en="<?php echo htmlspecialchars($en['blog_stack_4']); ?>">
<?php echo $text['blog_stack_4']; ?>
</li>
<li data-translate data-key="blog_stack_5"
data-en="<?php echo htmlspecialchars($en['blog_stack_5']); ?>">
<?php echo $text['blog_stack_5']; ?>
</li>
<li data-translate data-key="blog_stack_6"
data-en="<?php echo htmlspecialchars($en['blog_stack_6']); ?>">
<?php echo $text['blog_stack_6']; ?>
</li>
<li data-translate data-key="blog_stack_7"
data-en="<?php echo htmlspecialchars($en['blog_stack_7']); ?>">
<?php echo $text['blog_stack_7']; ?>
</li>
<li data-translate data-key="blog_stack_8"
data-en="<?php echo htmlspecialchars($en['blog_stack_8']); ?>">
<?php echo $text['blog_stack_8']; ?>
</li>
<li data-translate data-key="blog_stack_9"
data-en="<?php echo htmlspecialchars($en['blog_stack_9']); ?>">
<?php echo $text['blog_stack_9']; ?>
</li>
<li data-translate data-key="blog_stack_10"
data-en="<?php echo htmlspecialchars($en['blog_stack_10']); ?>">
<?php echo $text['blog_stack_10']; ?>
</li>
<li data-translate data-key="blog_stack_11"
data-en="<?php echo htmlspecialchars($en['blog_stack_11']); ?>">
<?php echo $text['blog_stack_11']; ?>
</li>
</ul>
</section>
</main>
<script>
const OTHER_PAGES = ['/index.php', '/cv.php'];
</script>
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
</body>
</html>

34
apps/website/cv-theme.js Normal file
View File

@ -0,0 +1,34 @@
const cvThemeButtons = [...document.querySelectorAll('[data-cv-theme]')];
const cvPortrait = document.getElementById('cv-portrait-orbit');
function setCvTheme(theme) {
const nextTheme = theme === 'fancy' ? 'fancy' : 'elegant';
document.body.classList.toggle('cv-fancy', nextTheme === 'fancy');
document.body.classList.toggle('cv-elegant', nextTheme === 'elegant');
cvThemeButtons.forEach((button) => {
const active = button.dataset.cvTheme === nextTheme;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
localStorage.setItem('cv-theme', nextTheme);
}
cvThemeButtons.forEach((button) => {
button.addEventListener('click', () => setCvTheme(button.dataset.cvTheme));
});
document.addEventListener('pointermove', (event) => {
if (!document.body.classList.contains('cv-fancy') || !cvPortrait) return;
const bounds = cvPortrait.getBoundingClientRect();
const centerX = bounds.left + bounds.width / 2;
const centerY = bounds.top + bounds.height / 2;
const radians = Math.atan2(event.clientY - centerY, event.clientX - centerX);
const degrees = radians * 180 / Math.PI;
cvPortrait.style.setProperty('--portrait-rotation', `${degrees + 8}deg`);
});
setCvTheme(localStorage.getItem('cv-theme') || 'elegant');

View File

@ -6,7 +6,7 @@
<title>CV - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<body class="cv-page cv-elegant">
<nav class="top-nav">
<div class="nav-left">Juvenal Diaz</div>
@ -25,22 +25,62 @@
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
<?php echo $text['nav_cv']; ?>
</a>
<a href="#"
<a href="blog.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_blog"
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
<?php echo $text['nav_blog']; ?>
</a>
<a href="demos.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_demos"
data-en="<?php echo htmlspecialchars($en['nav_demos']); ?>">
<?php echo $text['nav_demos']; ?>
</a>
</div>
</nav>
<div class="container">
<div class="cv-theme-toolbar" aria-label="<?php echo htmlspecialchars($en['cv_theme_label']); ?>">
<span data-translate data-key="cv_theme_label"
data-en="<?php echo htmlspecialchars($en['cv_theme_label']); ?>">
<?php echo $text['cv_theme_label']; ?>
</span>
<button type="button" class="cv-theme-option is-active" data-cv-theme="elegant"
data-translate data-key="cv_theme_elegant"
data-en="<?php echo htmlspecialchars($en['cv_theme_elegant']); ?>">
<?php echo $text['cv_theme_elegant']; ?>
</button>
<button type="button" class="cv-theme-option" data-cv-theme="fancy"
data-translate data-key="cv_theme_fancy"
data-en="<?php echo htmlspecialchars($en['cv_theme_fancy']); ?>">
<?php echo $text['cv_theme_fancy']; ?>
</button>
</div>
<div class="container cv-container">
<div class="cv-portrait-orbit" id="cv-portrait-orbit">
<svg viewBox="0 0 260 260" aria-hidden="true">
<defs>
<path id="cv-orbit-text-path" d="M 130,130 m -104,0 a 104,104 0 1,1 208,0 a 104,104 0 1,1 -208,0"></path>
</defs>
<text>
<textPath href="#cv-orbit-text-path" startOffset="0%"
data-translate data-key="cv_orbit_text"
data-en="<?php echo htmlspecialchars($en['cv_orbit_text']); ?>">
<?php echo $text['cv_orbit_text']; ?>
</textPath>
</text>
</svg>
<img class="cv-portrait-img" src="images/profile.webp" alt="Juvenal Diaz">
</div>
<div class="cv-header-text">
<h1><?php echo $text['name']; ?></h1>
<p data-translate data-key="job_title"
data-en="<?php echo htmlspecialchars($en['job_title']); ?>">
<?php echo $text['job_title']; ?>
</p>
<p><?php echo $text['contacts']; ?></p>
</div>
<h2 data-translate data-key="cv_summary_title"
data-en="<?php echo htmlspecialchars($en['cv_summary_title']); ?>">
@ -128,6 +168,10 @@
</div>
<script>
const OTHER_PAGES = ['/index.php', '/blog.php'];
</script>
<script src="/cv-theme.js"></script>
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
</body>

155
apps/website/demos.php Normal file
View File

@ -0,0 +1,155 @@
<?php
require_once __DIR__ . '/lang_helper.php';
$demoCards = [
[
'label' => 'demo_cruncher_label',
'title' => 'demo_cruncher_title',
'description' => 'demo_cruncher_desc',
'href' => '/demo-apps/media-cruncher/',
'icon' => 'cruncher-icon',
],
[
'label' => 'demo_network_label',
'title' => 'demo_network_title',
'description' => 'demo_network_desc',
'href' => '/demo-apps/network-quality/',
'icon' => 'demo-icon network-icon',
],
[
'label' => 'demo_toolbelt_label',
'title' => 'demo_toolbelt_title',
'description' => 'demo_toolbelt_desc',
'href' => '/demo-apps/dev-toolbelt/',
'icon' => 'demo-icon toolbelt-icon',
],
[
'label' => 'demo_arch_label',
'title' => 'demo_arch_title',
'description' => 'demo_arch_desc',
'href' => '/demo-apps/architecture-simulator/',
'icon' => 'demo-icon architecture-icon',
],
[
'label' => 'demo_traveler_label',
'title' => 'demo_traveler_title',
'description' => 'demo_traveler_desc',
'href' => '/demo-apps/traveler-tools/',
'icon' => 'demo-icon traveler-icon',
],
[
'label' => 'demo_redactor_label',
'title' => 'demo_redactor_title',
'description' => 'demo_redactor_desc',
'href' => '/demo-apps/privacy-redactor/',
'icon' => 'demo-icon redactor-icon',
],
[
'label' => 'demo_sentiment_label',
'title' => 'demo_sentiment_title',
'description' => 'demo_sentiment_desc',
'href' => '/demo-apps/sentiment-sandbox/',
'icon' => 'demo-icon sentiment-icon',
],
[
'label' => 'demo_drift_label',
'title' => 'demo_drift_title',
'description' => 'demo_drift_desc',
'href' => '/demo-apps/model-drift/',
'icon' => 'demo-icon drift-icon',
],
];
?>
<!DOCTYPE html>
<html lang="<?php echo $lang; ?>">
<head>
<meta charset="UTF-8">
<title><?php echo $text['demos_title']; ?> - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="top-nav">
<div class="nav-left">Juvenal Diaz</div>
<div class="nav-right">
<?php foreach ($availableLangs as $code): ?>
<a href="demos.php?lang=<?php echo $code; ?>"><?php echo strtoupper($code); ?></a>
<?php endforeach; ?>
|
<a href="index.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_home"
data-en="<?php echo htmlspecialchars($en['nav_home']); ?>">
<?php echo $text['nav_home']; ?>
</a>
<a href="cv.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_cv"
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
<?php echo $text['nav_cv']; ?>
</a>
<a href="blog.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_blog"
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
<?php echo $text['nav_blog']; ?>
</a>
<a href="demos.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_demos"
data-en="<?php echo htmlspecialchars($en['nav_demos']); ?>">
<?php echo $text['nav_demos']; ?>
</a>
</div>
</nav>
<main class="demos-page">
<section class="demos-hero">
<p class="blog-kicker"
data-translate data-key="demos_kicker"
data-en="<?php echo htmlspecialchars($en['demos_kicker']); ?>">
<?php echo $text['demos_kicker']; ?>
</p>
<h1 data-translate data-key="demos_title"
data-en="<?php echo htmlspecialchars($en['demos_title']); ?>">
<?php echo $text['demos_title']; ?>
</h1>
<p data-translate data-key="demos_subtitle"
data-en="<?php echo htmlspecialchars($en['demos_subtitle']); ?>">
<?php echo $text['demos_subtitle']; ?>
</p>
</section>
<section class="demo-grid" aria-label="<?php echo htmlspecialchars($en['demos_title']); ?>">
<?php foreach ($demoCards as $card): ?>
<a class="demo-card demo-catalog-card" href="<?php echo htmlspecialchars($card['href']); ?>">
<div class="demo-card-header">
<div class="<?php echo htmlspecialchars($card['icon']); ?>" aria-hidden="true">
<?php if ($card['icon'] === 'cruncher-icon'): ?>
<span class="cruncher-icon-file"></span>
<span class="cruncher-icon-spark"></span>
<?php elseif ($card['icon'] === 'demo-icon network-icon'): ?>
<span></span><span></span><span></span>
<?php elseif ($card['icon'] === 'demo-icon toolbelt-icon'): ?>
{ }
<?php endif; ?>
</div>
<div>
<p class="demo-label"
data-translate data-key="<?php echo htmlspecialchars($card['label']); ?>"
data-en="<?php echo htmlspecialchars($en[$card['label']]); ?>">
<?php echo htmlspecialchars($text[$card['label']]); ?>
</p>
<h2 data-translate data-key="<?php echo htmlspecialchars($card['title']); ?>"
data-en="<?php echo htmlspecialchars($en[$card['title']]); ?>">
<?php echo htmlspecialchars($text[$card['title']]); ?>
</h2>
</div>
</div>
<p data-translate data-key="<?php echo htmlspecialchars($card['description']); ?>"
data-en="<?php echo htmlspecialchars($en[$card['description']]); ?>">
<?php echo htmlspecialchars($text[$card['description']]); ?>
</p>
</a>
<?php endforeach; ?>
</section>
</main>
</body>
</html>

View File

@ -25,11 +25,16 @@
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
<?php echo $text['nav_cv']; ?>
</a>
<a href="#"
<a href="blog.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_blog"
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
<?php echo $text['nav_blog']; ?>
</a>
<a href="demos.php?lang=<?php echo $lang; ?>"
data-translate data-key="nav_demos"
data-en="<?php echo htmlspecialchars($en['nav_demos']); ?>">
<?php echo $text['nav_demos']; ?>
</a>
</div>
</nav>
@ -73,7 +78,7 @@
// Tell translation.js to also translate these pages in the background
?>
<script>
const OTHER_PAGES = ['/cv.php'];
const OTHER_PAGES = ['/cv.php', '/blog.php'];
</script>
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>

View File

@ -8,6 +8,7 @@ return [
'nav_home' => 'Home',
'nav_cv' => 'CV',
'nav_blog' => 'Blog',
'nav_demos' => 'Demos',
// Index bio
'bio_intro' => 'I work in infrastructure and reliability, focusing on building systems that are stable, scalable, and easy to operate.',
@ -20,6 +21,10 @@ return [
// CV sections
'cv_summary_title' => 'Professional Summary',
'cv_summary' => 'IT Professional with 12+ years of experience, specializing in Linux but also proficient in team management (local and global teams) and user satisfaction. My greatest strength is a sense of urgency which enables me to tackle issues in the most fast and efficient way, always focusing on continuous improvement and service excellence. I also enjoy learning new technologies as required.',
'cv_theme_label' => 'CV theme',
'cv_theme_elegant' => 'Elegant',
'cv_theme_fancy' => 'Fancy',
'cv_orbit_text' => 'Reliability, Linux, Kubernetes, automation, and just enough drama to keep the resume awake.',
'cv_employment_title' => 'Employment History / Activities',
@ -50,4 +55,66 @@ return [
'cv_job7_period' => 'February 2013 → August 2015',
'cv_job7_title' => 'Customer Support Agent Teleperformance | Comcast',
'cv_job7_desc' => 'Provided customer support services taking calls from the US Southwest area to troubleshoot cable, phone, and internet services.',
// Blog
'blog_kicker' => 'Homelab field notes',
'blog_title' => 'I accidentally built a tiny CI/CD platform',
'blog_subtitle' => 'A casual conversation about how a Debian box, a Raspberry Pi, an OCI edge host, and a suspicious amount of stubbornness became a repeatable Kubernetes delivery path.',
'blog_speaker_question' => 'Future me, judging',
'blog_speaker_answer' => 'Me, holding coffee',
'blog_q1' => 'Be honest: why build all this instead of just running a couple containers like a normal person?',
'blog_a1' => 'Because apparently I looked at "host a website" and thought, "what if this had a control plane, GitOps, retained storage, an image registry, and several new ways to embarrass myself?" The real goal was practice: provision the infra, keep config in Git, deploy with automation, break it, fix it, and make sure I could rebuild it without relying on shell history and vibes.',
'blog_q2' => 'Why kubeadm? Were managed clusters too emotionally stable?',
'blog_a2' => 'Pretty much. kubeadm keeps the cluster close to the metal, which is a polite way of saying I get to see every sharp edge. The Debian node runs the control plane, the Raspberry Pi joins as an arm64 worker, and suddenly networking, storage, container runtimes, certs, and node recovery are not mysterious cloud magic. They are my problem. Educational, in the same way stepping on a rake is educational.',
'blog_q3' => 'So where is the CI/CD part hiding?',
'blog_a3' => 'It is small, but it is real. OpenTofu brings up the cluster, platform, apps, and edge layers. Argo CD watches Git and keeps the cluster honest. Docker Buildx builds the PHP website for linux/arm64, pushes it to the local registry, and then the workload rolls forward. No enterprise dashboard fireworks, just a clean loop that says: Git changed, image built, cluster updated, nobody had to kubectl-edit anything at 2 AM.',
'blog_q4' => 'Why run your own registry and Gitea? Was the simple option unavailable?',
'blog_a4' => 'The simple option was very available, which is why I heroically ignored it. The registry means experiments do not need to go to a public image repo, and Gitea gives the lab its own Git service. Together they make the setup feel less like "some containers under the stairs" and more like a tiny platform with opinions, responsibilities, and occasionally dramatic storage needs.',
'blog_q5' => 'What actually hurt the most?',
'blog_a5' => 'Storage. Always storage. Kubernetes, Docker, retained volumes, and build caches can fill a small root disk with the quiet confidence of a bad decision. Moving OpenEBS local volumes and Docker data to the external SSD turned the lab from "why is everything on fire?" into "okay, this is usable now." Growth, allegedly.',
'blog_q6' => 'And now the website has demos and a weirdly expressive CV?',
'blog_a6' => 'Correct. The CV now has an Elegant mode for terminal-green seriousness and a Fancy mode where my face follows the cursor like it has opinions. The Demos page is now a catalog that links to a separate demos-static artifact, because apparently the natural next step after building a platform is learning not to shove every toy into the same image.',
'blog_q7' => 'Can the current cluster actually handle all that, or are we about to smoke the Pi?',
'blog_a7' => 'The Pi survives because the demos are intentionally local-first and now ship as a separate static artifact. The website pod stays a portfolio shell, the demos-static pod serves static bundles, and the user browser does the expensive work. If I later ship real ONNX object detection, Transformers.js, or full video transcoding models, those must lazy-load in the browser or move to a beefier node. The Raspberry Pi is brave, but it is not a GPU wearing a tiny hat.',
'blog_stack_title' => 'Technologies and why they are here',
'blog_stack_1' => 'Debian Linux is the steady adult in the room: control plane host, deployment workstation, and the place where OpenTofu, Docker, kubeadm, and the scripts do their thing.',
'blog_stack_2' => 'Raspberry Pi adds an arm64 worker, which is great for learning multi-architecture builds and for reminding me that CPU architecture is not a decorative detail.',
'blog_stack_3' => 'OpenTofu makes the cluster, platform, apps, and edge config repeatable, because "I swear I remember the command" is not a disaster recovery strategy.',
'blog_stack_4' => 'Calico handles pod networking, and OpenEBS hostpath storage keeps the important data around after rebuilds, because deleting everything by accident is only funny once.',
'blog_stack_5' => 'Argo CD is the GitOps referee: manifests live in Git, the cluster follows along, and manual drift gets side-eyed back into place.',
'blog_stack_6' => 'The OCI edge host runs nginx, HAProxy, Varnish, and Squid so TLS, routing, and caching stay outside the home network while Tailscale sneaks the traffic back to the worker node.',
'blog_stack_7' => 'The CV theme toggle is plain CSS and JavaScript, which is all it needs: one mode for console nostalgia, one mode for cursor-following nonsense with manners.',
'blog_stack_8' => 'The first demo keeps files in the browser. Image crunching uses native Canvas APIs today, while the fast serious path for video conversion is Rust compiled to WebAssembly with a TypeScript UI.',
'blog_stack_9' => 'The newer demos cover network jitter graphs, local JSON/JWT/log tools, an architecture simulator, an offline traveler converter, a redactor prototype, sentiment analysis, and model-drift simulation.',
'blog_stack_10' => 'The heavier ML demos are designed as client-side Wasm/ONNX/Transformers.js candidates, not server-side jobs. That keeps the homelab app boring to operate, which is secretly the whole point.',
'blog_stack_11' => 'The demo code now builds into its own demos-static image and Argo CD app, exposed at /demo-apps/. The PHP website only owns the catalog link, which is much less cursed.',
// Demos
'demos_kicker' => 'Small tools, real browser work',
'demos_title' => 'Demo Apps',
'demos_subtitle' => 'A growing shelf of small apps shipped as separate static demo artifacts. The website stays light; each demo gets its own page under /demo-apps/.',
'demo_cruncher_label' => 'Demo 01',
'demo_cruncher_title' => 'The Client-Side Media Cruncher (Wasm + TS)',
'demo_cruncher_desc' => 'Drop in a large image and convert or compress it locally. The browser does the work, the server sees nothing, and your file does not take a suspicious vacation through a random converter site.',
'demo_network_label' => 'Demo 02',
'demo_network_title' => 'How Is My Internet, Really?',
'demo_network_desc' => 'A live Canvas dashboard that samples latency to this site, estimates jitter, and visualizes stability instead of pretending one speed-test number tells the whole story.',
'demo_toolbelt_label' => 'Demo 03',
'demo_toolbelt_title' => 'Local Log and JSON Toolbelt',
'demo_toolbelt_desc' => 'Prettify JSON, decode JWT payloads, parse URLs, and grep text logs locally without pasting private data into mystery websites.',
'demo_arch_label' => 'Demo 04',
'demo_arch_title' => 'Interactive System Architecture Simulator',
'demo_arch_desc' => 'A tiny traffic playground where users, load balancers, web nodes, and a database show how systems scale, fail, and recover.',
'demo_traveler_label' => 'Demo 05',
'demo_traveler_title' => 'Offline Traveler Converter',
'demo_traveler_desc' => 'A PWA-style timezone, currency, and data-unit converter for flights, remote teams, and those meetings that somehow happen tomorrow and yesterday.',
'demo_redactor_label' => 'Demo 06',
'demo_redactor_title' => 'Privacy-First Object Redactor',
'demo_redactor_desc' => 'Drop an image, blur sensitive regions locally, and download the redacted result. No upload, no backend, no awkward explanation to security.',
'demo_sentiment_label' => 'Demo 07',
'demo_sentiment_title' => 'Local Sentiment and Text Analytics',
'demo_sentiment_desc' => 'Paste reviews, support notes, or essays and get instant local sentiment, keywords, and a tiny summary without calling an API.',
'demo_drift_label' => 'Demo 08',
'demo_drift_title' => 'Model Drift and Performance Simulator',
'demo_drift_desc' => 'A visual MLOps playground where traffic spikes and corrupted inputs drag model accuracy down until retraining brings it back.',
];

View File

@ -11,6 +11,7 @@ return [
'nav_home' => 'Nochan', // My home
'nav_cv' => 'Notlahcuilol', // My document/record
'nav_blog' => 'Notlahtol', // My words
'nav_demos' => 'Tlayeyecoliztli',
// Index bio
'bio_intro' => 'Nitlatequitia ipan tlatecpanaliztli ihuan tlayeyecoliztli, niquitta in quenin tiquitasque tlapatlaliztli tlayecoliztli tlamantli nemiztli.',
@ -31,6 +32,10 @@ return [
// CV sections
'cv_summary_title' => 'Notequitl Tlahcuilolli',
'cv_summary' => 'Tlapixqui āmantēcayōtl inic matlactli omome xihuitl, motemachtia Linux ihuan quimatia tlatecpanaliztli (ipan altepetl ihuan tlalpan) ihuan tlahtoa tlacame. Nohueyitequitl ic tlaneltoquiliztli niquixehua tlaneltoquiliztli inic achi ic niquichihua, moch ica tlapatlaliztli ihuan tlatequipanoliztli. Nixpampa nimati āmantēcayōtl yancuic quenin monequi.',
'cv_theme_label' => 'CV theme',
'cv_theme_elegant' => 'Elegant',
'cv_theme_fancy' => 'Fancy',
'cv_orbit_text' => 'Reliability, Linux, Kubernetes, automation, ihuan achi drama inic resume nemi.',
'cv_employment_title' => 'Notequitl Tlahcuilolli / Tlatequipanoliztli',
@ -61,4 +66,66 @@ return [
'cv_job7_period' => 'Febrero 2013 → Agosto 2015',
'cv_job7_title' => 'Tlapalehuiani Tlacame Teleperformance | Comcast',
'cv_job7_desc' => 'Nitlapalehua tlacame ipan US inic cable, tepoztli, ihuan tlahtoa tlamantli.',
// Blog
'blog_kicker' => 'Homelab tlahcuilolli',
'blog_title' => 'Tlatecpanaliztli homelab CI/CD pipeline',
'blog_subtitle' => 'Ce tlahtolli in quenin Debian server, Raspberry Pi, ihuan OCI edge box mochihua ce Kubernetes tlatequipanoliztli.',
'blog_speaker_question' => 'Nehuatl mostla',
'blog_speaker_answer' => 'Nehuatl axcan',
'blog_q1' => 'Tleica niquichihua inin ihuan ahmo zan container tlatequipanoa?',
'blog_a1' => 'Ahmo zan website. Niquinequi nicnemiliz in operating model: infrastructure, Git, automation, recovery, ihuan reproducible rebuild.',
'blog_q2' => 'Tleica kubeadm ihuan ahmo managed Kubernetes?',
'blog_a2' => 'kubeadm quipia cluster nechca metal. Debian quipia control plane ihuan Raspberry Pi mochihua arm64 worker, ic niquita networking, storage, runtime, certificates, ihuan node recovery.',
'blog_q3' => 'Canin nemi CI/CD ipan inin setup?',
'blog_a3' => 'Pipeline achi tepiton. OpenTofu quichihua cluster, platform, apps, ihuan edge. Argo CD quitta Git repo ihuan quichihua sync. Docker Buildx quichihua PHP website image para linux/arm64 ihuan quipush ipan local registry.',
'blog_q4' => 'Tleica private registry ihuan Gitea ipan lab?',
'blog_a4' => 'Registry amo monequi nicpush nochi experiment ipan public repo. Gitea quimaca lab se Git service. In ome quichihua ce tepiton production platform.',
'blog_q5' => 'Tlein achi ohui omomachtih?',
'blog_a5' => 'Storage. Kubernetes, Docker, retained volumes, ihuan build cache huel quitemitia root disk. OpenEBS ihuan Docker data omoyecpan ipan external SSD, ic system achi yec nemi.',
'blog_q6' => 'Ihuan axcan website quipia demos ihuan CV occeppa?',
'blog_a6' => 'Quena. CV quipia Elegant mode para console green ihuan Fancy mode canin noxayac quitta cursor. Demos page axcan catalog ihuan demos-static artifact.',
'blog_q7' => 'Cluster huel quipias nochi demos?',
'blog_a7' => 'Quena, pampa demos cateh local-first ihuan separate static artifact. Website pod zan shell, demos-static pod quimaca bundles, browser quichihua tequitl. Real ONNX, Transformers.js, o video transcoding monequi lazy-load o occe node hueyi.',
'blog_stack_title' => 'Tlamantli ihuan tleica nemi nican',
'blog_stack_1' => 'Debian Linux quimaca stable control-plane host ihuan canin nemi OpenTofu, Docker, kubeadm, ihuan scripts.',
'blog_stack_2' => 'Raspberry Pi quimaca arm64 worker inic niyeyecoa multi-architecture builds ihuan node placement.',
'blog_stack_3' => 'OpenTofu quichihua cluster, platform, apps, ihuan edge configuration reproducible.',
'blog_stack_4' => 'Calico quimati pod networking; OpenEBS hostpath storage quipia data ipan cluster rebuilds.',
'blog_stack_5' => 'Argo CD quimaca GitOps control loop: manifests cateh ipan Git ihuan cluster moyecpana.',
'blog_stack_6' => 'OCI edge host quipia nginx, HAProxy, Varnish, ihuan Squid para TLS, routing, ihuan cache, ihuan Tailscale quihuica traffic ipan worker node.',
'blog_stack_7' => 'CV theme toggle quipia CSS ihuan JavaScript: ce mode console, occe mode cursor-following.',
'blog_stack_8' => 'Demo achto quipia files ipan browser. Image crunching quimati Canvas; video conversion quinequi Rust WebAssembly ihuan TypeScript UI.',
'blog_stack_9' => 'Yancuic demos quipia network jitter graphs, local JSON/JWT/log tools, architecture simulator, offline traveler converter, redactor, sentiment analysis, ihuan model drift simulation.',
'blog_stack_10' => 'ML demos monequi client-side Wasm/ONNX/Transformers.js, ahmo server-side jobs.',
'blog_stack_11' => 'Demo code axcan quichihua demos-static image ihuan Argo CD app, exposed ipan /demo-apps/. PHP website zan catalog.',
// Demos
'demos_kicker' => 'Tepiton tools ipan browser',
'demos_title' => 'Demo Apps',
'demos_subtitle' => 'Tepiton apps ipan separate static demo artifacts. Website mocahua light; demos cateh ipan /demo-apps/.',
'demo_cruncher_label' => 'Demo 01',
'demo_cruncher_title' => 'The Client-Side Media Cruncher (Wasm + TS)',
'demo_cruncher_desc' => 'Xictlali huey image ihuan browser quichihua compression o conversion local. Server amo quitta file.',
'demo_network_label' => 'Demo 02',
'demo_network_title' => 'How Is My Internet, Really?',
'demo_network_desc' => 'Canvas dashboard quimati latency, jitter, ihuan stability ipan browser.',
'demo_toolbelt_label' => 'Demo 03',
'demo_toolbelt_title' => 'Local Log and JSON Toolbelt',
'demo_toolbelt_desc' => 'JSON format, JWT decode, URL parse, ihuan log filter local.',
'demo_arch_label' => 'Demo 04',
'demo_arch_title' => 'Interactive System Architecture Simulator',
'demo_arch_desc' => 'Traffic playground: users, load balancer, web nodes, ihuan database.',
'demo_traveler_label' => 'Demo 05',
'demo_traveler_title' => 'Offline Traveler Converter',
'demo_traveler_desc' => 'Timezone, currency, ihuan data-unit converter para travel ihuan remote teams.',
'demo_redactor_label' => 'Demo 06',
'demo_redactor_title' => 'Privacy-First Object Redactor',
'demo_redactor_desc' => 'Xictlali image, blur sensitive regions local, ihuan download result.',
'demo_sentiment_label' => 'Demo 07',
'demo_sentiment_title' => 'Local Sentiment and Text Analytics',
'demo_sentiment_desc' => 'Xictlali text ihuan quitta sentiment, keywords, ihuan tepiton summary local.',
'demo_drift_label' => 'Demo 08',
'demo_drift_title' => 'Model Drift and Performance Simulator',
'demo_drift_desc' => 'MLOps playground canin traffic spikes ihuan corrupted inputs quitemoa model accuracy.',
];

View File

@ -24,7 +24,6 @@ if (!file_exists($file)) {
$file = __DIR__ . "/lang/nah.php";
}
$text = include $file;
// Always load English as translation source
$en = include __DIR__ . '/lang/en.php';
$text = array_replace($en, include $file);

View File

@ -3,9 +3,11 @@
<div class="nav-right">
<a href="index.php?lang=<?php echo $lang; ?>">Home</a>
<a href="cv.php?lang=<?php echo $lang; ?>">CV</a>
<a href="#">Blog</a>
<a href="blog.php?lang=<?php echo $lang; ?>">Blog</a>
<a href="demos.php?lang=<?php echo $lang; ?>">Demos</a>
|
<a href="?lang=en">EN</a>
<a href="?lang=es">ES</a>
<?php foreach ($availableLangs as $code): ?>
<a href="<?php echo basename($_SERVER['PHP_SELF']); ?>?lang=<?php echo $code; ?>"><?php echo strtoupper($code); ?></a>
<?php endforeach; ?>
</div>
</nav>

View File

@ -203,3 +203,592 @@ body {
object-fit: cover;
border-radius: 50%;
}
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 32px;
}
.nav-left {
font-weight: 700;
color: #004085;
}
.nav-right {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nav-right a {
color: #004085;
text-decoration: none;
font-weight: 600;
}
.nav-right a:hover {
text-decoration: underline;
}
.blog-page {
max-width: 980px;
margin: 0 auto;
}
.blog-hero {
margin-bottom: 28px;
padding-bottom: 22px;
border-bottom: 1px solid #d9e2ec;
}
.blog-kicker {
margin: 0 0 10px;
color: #004085;
font-weight: 700;
text-transform: uppercase;
font-size: 0.85rem;
}
.blog-hero h1 {
margin: 0;
color: #102a43;
font-size: 2.4rem;
}
.blog-subtitle {
max-width: 760px;
color: #52606d;
line-height: 1.6;
font-size: 1.08rem;
}
.conversation {
display: grid;
gap: 18px;
}
.message {
max-width: 780px;
padding: 18px 20px;
border-radius: 8px;
line-height: 1.6;
}
.message.question {
justify-self: start;
background: #fff;
border-left: 4px solid #829ab1;
}
.message.answer {
justify-self: end;
background: #eef5ff;
border-left: 4px solid #004085;
}
.speaker {
margin-bottom: 8px;
color: #102a43;
font-size: 0.9rem;
font-weight: 700;
}
.message p {
margin: 0;
}
.tech-notes {
margin-top: 34px;
padding: 24px;
background: #fff;
border-radius: 8px;
border: 1px solid #d9e2ec;
}
.tech-notes h2 {
margin-top: 0;
color: #004085;
}
.tech-notes li {
margin-bottom: 12px;
line-height: 1.55;
}
@media (max-width: 768px) {
.top-nav {
align-items: flex-start;
flex-direction: column;
}
.blog-hero h1 {
font-size: 2rem;
}
.message,
.message.answer {
justify-self: stretch;
}
}
/* CV theme toggle */
.cv-theme-toolbar {
max-width: 900px;
margin: 0 auto 18px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
font-size: 0.9rem;
}
.cv-theme-option {
border: 1px solid #9fb3c8;
background: #fff;
color: #102a43;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
}
.cv-theme-option.is-active {
background: #004085;
border-color: #004085;
color: #fff;
}
.cv-container {
position: relative;
min-height: 520px;
}
.cv-portrait-orbit {
--portrait-rotation: 0deg;
width: 160px;
height: 160px;
z-index: 2;
}
.cv-portrait-orbit svg {
display: none;
}
.cv-portrait-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
body.cv-elegant {
background: #030603;
color: #a8ffb0;
font-family: "Courier New", "Lucida Console", Monaco, monospace;
}
.cv-elegant .top-nav,
.cv-elegant .cv-theme-toolbar {
color: #a8ffb0;
}
.cv-elegant .nav-left,
.cv-elegant .nav-right a {
color: #a8ffb0;
}
.cv-elegant .cv-theme-option {
background: #061106;
border-color: #2b7a38;
color: #a8ffb0;
font-family: inherit;
}
.cv-elegant .cv-theme-option.is-active {
background: #143b18;
border-color: #a8ffb0;
}
.cv-elegant .cv-container {
background: #061106;
border: 1px solid #2b7a38;
border-radius: 0;
box-shadow: 0 0 18px rgba(168, 255, 176, 0.12);
}
.cv-elegant .cv-container h1,
.cv-elegant .cv-container h2,
.cv-elegant .cv-container strong {
color: #c7ffd0;
font-family: inherit;
}
.cv-elegant .cv-container p {
color: #a8ffb0;
line-height: 1.55;
}
.cv-elegant .cv-header-text {
min-height: 160px;
padding-right: 190px;
}
.cv-elegant .cv-portrait-orbit {
position: absolute;
top: 30px;
right: 30px;
}
.cv-elegant .cv-portrait-img {
border-radius: 0;
border: 2px solid #a8ffb0;
filter: saturate(0.8) contrast(1.08);
}
body.cv-fancy {
background: #f7f0e8;
color: #37251f;
font-family: Georgia, "Times New Roman", serif;
}
.cv-fancy .top-nav {
color: #5f3f35;
}
.cv-fancy .nav-left,
.cv-fancy .nav-right a {
color: #6f3d53;
}
.cv-fancy .cv-theme-option {
border-color: #c29aac;
background: #fffaf7;
color: #6f3d53;
}
.cv-fancy .cv-theme-option.is-active {
background: #6f3d53;
border-color: #6f3d53;
color: #fff;
}
.cv-fancy .cv-container {
background: #fffaf7;
border: 1px solid #ead5c6;
box-shadow: 0 18px 40px rgba(95, 63, 53, 0.16);
}
.cv-fancy .cv-header-text {
text-align: center;
}
.cv-fancy .cv-header-text h1 {
color: #6f3d53;
font-family: "Brush Script MT", "Segoe Script", cursive;
font-size: 3rem;
font-weight: 400;
}
.cv-fancy .cv-portrait-orbit {
position: relative;
margin: 8px auto 28px;
width: 260px;
height: 260px;
}
.cv-fancy .cv-portrait-orbit svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
animation: orbitText 24s linear infinite;
}
.cv-fancy .cv-portrait-orbit text {
fill: #6f3d53;
font-family: "Brush Script MT", "Segoe Script", cursive;
font-size: 18px;
letter-spacing: 1px;
}
.cv-fancy .cv-portrait-img {
position: absolute;
inset: 45px;
width: 170px;
height: 170px;
border-radius: 50%;
border: 8px solid #fff;
box-shadow: 0 12px 24px rgba(95, 63, 53, 0.2);
transform: rotate(var(--portrait-rotation));
transition: transform 120ms ease-out;
}
@keyframes orbitText {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Demos page */
.demos-page {
max-width: 1040px;
margin: 0 auto;
}
.demos-hero {
margin-bottom: 24px;
}
.demos-hero h1 {
margin: 0;
color: #102a43;
font-size: 2.4rem;
}
.demos-hero p {
max-width: 760px;
color: #52606d;
line-height: 1.6;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 18px;
}
.demo-card {
display: block;
background: #fff;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 24px;
box-shadow: 0 12px 28px rgba(16, 42, 67, 0.08);
color: inherit;
text-decoration: none;
}
.demo-catalog-card:hover {
border-color: #9fb3c8;
transform: translateY(-2px);
transition: border-color 160ms ease, transform 160ms ease;
}
.demo-card-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.demo-label {
margin: 0 0 4px;
color: #2f855a;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.demo-card h2 {
margin: 0;
color: #102a43;
}
.cruncher-icon {
position: relative;
width: 62px;
height: 62px;
flex: 0 0 auto;
border-radius: 8px;
background: #e8fff1;
border: 1px solid #b7ebc6;
}
.cruncher-icon-file {
position: absolute;
left: 17px;
top: 12px;
width: 28px;
height: 38px;
border-radius: 4px;
background: #fff;
border: 2px solid #2f855a;
box-shadow: 6px 6px 0 #b7ebc6;
}
.cruncher-icon-file::before,
.cruncher-icon-file::after {
content: "";
position: absolute;
left: 6px;
right: 6px;
height: 2px;
background: #2f855a;
}
.cruncher-icon-file::before {
top: 12px;
}
.cruncher-icon-file::after {
top: 20px;
}
.cruncher-icon-spark {
position: absolute;
right: 8px;
top: 8px;
width: 10px;
height: 10px;
background: #f6ad55;
transform: rotate(45deg);
}
.demo-icon {
display: grid;
place-items: center;
width: 62px;
height: 62px;
flex: 0 0 auto;
border-radius: 8px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #102a43;
font-weight: 900;
}
.network-icon {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 10px;
box-sizing: border-box;
}
.network-icon span {
display: block;
align-self: end;
width: 100%;
border-radius: 999px 999px 0 0;
background: #2563eb;
}
.network-icon span:nth-child(1) {
height: 18px;
}
.network-icon span:nth-child(2) {
height: 34px;
}
.network-icon span:nth-child(3) {
height: 26px;
}
.toolbelt-icon {
background: #fff7ed;
border-color: #fed7aa;
color: #9a3412;
font-family: "Courier New", monospace;
}
.architecture-icon {
position: relative;
background: #eef5ff;
border-color: #bfdbfe;
}
.architecture-icon::before,
.architecture-icon::after {
content: "";
position: absolute;
border-radius: 8px;
background: #2563eb;
}
.architecture-icon::before {
width: 34px;
height: 12px;
top: 15px;
}
.architecture-icon::after {
width: 44px;
height: 12px;
bottom: 15px;
}
.traveler-icon {
background: #ecfeff;
border-color: #a5f3fc;
}
.traveler-icon::before {
content: "↔";
color: #0e7490;
font-size: 30px;
}
.redactor-icon {
background: #fff1f2;
border-color: #fecdd3;
}
.redactor-icon::before {
content: "";
width: 38px;
height: 24px;
border-radius: 6px;
background: repeating-linear-gradient(135deg, #be123c 0 7px, #ffe4e6 7px 14px);
}
.sentiment-icon {
background: #f0fdf4;
border-color: #bbf7d0;
}
.sentiment-icon::before {
content: "Aa";
color: #166534;
font-family: Georgia, serif;
font-size: 24px;
}
.drift-icon {
background: #faf5ff;
border-color: #e9d5ff;
}
.drift-icon::before {
content: "∿";
color: #7e22ce;
font-size: 38px;
}
@media (max-width: 640px) {
.cv-theme-toolbar {
align-items: stretch;
flex-direction: column;
}
.cv-elegant .cv-header-text {
padding-right: 0;
padding-top: 178px;
}
.cv-elegant .cv-portrait-orbit {
left: 30px;
right: auto;
}
.cv-fancy .cv-header-text h1 {
font-size: 2.35rem;
}
}

View File

@ -52,5 +52,14 @@ variable "applications" {
self_heal = true
create_namespace = true
}
demos-static = {
project = "default"
path = "apps/demos-static"
namespace = "demos-static"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
}
}

View File

@ -298,12 +298,12 @@ resource "null_resource" "kubeadm_worker" {
registry_config_version = "6"
node_dns_servers = join(" ", var.node_dns_servers)
persistent_volume_dirs = join(",", var.persistent_volume_dirs)
tailscale_nodeport_version = "1"
tailscale_nodeport_version = "2"
tailscale_nodeport_enabled = var.tailscale_nodeport_access.enabled && each.key == var.tailscale_nodeport_access.worker_key ? "true" : "false"
tailscale_nodeport_peer_ip = var.tailscale_nodeport_access.peer_ip
tailscale_nodeport_node_tailscale_ip = var.tailscale_nodeport_access.node_tailscale_ip
tailscale_nodeport_pod_cidr = var.tailscale_nodeport_access.pod_cidr
tailscale_nodeport_node_port = tostring(var.tailscale_nodeport_access.node_port)
tailscale_nodeport_node_ports = join(" ", distinct(concat([var.tailscale_nodeport_access.node_port], var.tailscale_nodeport_extra_ports)))
tailscale_nodeport_target_port = tostring(var.tailscale_nodeport_access.target_port)
}
@ -530,7 +530,7 @@ configure_tailscale_nodeport_access() {
local peer_ip="$2"
local node_tailscale_ip="$3"
local pod_cidr="$4"
local node_port="$5"
local node_ports="$5"
local target_port="$6"
if [ "$enabled" != "true" ]; then
@ -557,8 +557,10 @@ fi
ip route replace "$peer_ip/32" dev tailscale0 src "$node_tailscale_ip"
iptables -C INPUT -i tailscale0 -p tcp --dport "$node_port" -j ACCEPT 2>/dev/null ||
iptables -I INPUT 1 -i tailscale0 -p tcp --dport "$node_port" -j ACCEPT
for node_port in $node_ports; do
iptables -C INPUT -i tailscale0 -p tcp --dport "\$node_port" -j ACCEPT 2>/dev/null ||
iptables -I INPUT 1 -i tailscale0 -p tcp --dport "\$node_port" -j ACCEPT
done
iptables -C FORWARD -i tailscale0 -d "$pod_cidr" -p tcp --dport "$target_port" -j ACCEPT 2>/dev/null ||
iptables -I FORWARD 1 -i tailscale0 -d "$pod_cidr" -p tcp --dport "$target_port" -j ACCEPT
iptables -C FORWARD -s "$pod_cidr" -o tailscale0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null ||
@ -592,7 +594,7 @@ configure_tailscale_nodeport_access \
"${self.triggers.tailscale_nodeport_peer_ip}" \
"${self.triggers.tailscale_nodeport_node_tailscale_ip}" \
"${self.triggers.tailscale_nodeport_pod_cidr}" \
"${self.triggers.tailscale_nodeport_node_port}" \
"${self.triggers.tailscale_nodeport_node_ports}" \
"${self.triggers.tailscale_nodeport_target_port}"
configure_containerd_registry "${self.triggers.registry_endpoint}"
@ -636,4 +638,3 @@ output "kubeconfig_path" {
output "pod_network_cidr" {
value = var.pod_network_cidr
}

View File

@ -83,3 +83,8 @@ variable "tailscale_nodeport_access" {
target_port = 80
}
}
variable "tailscale_nodeport_extra_ports" {
type = list(number)
default = [30081]
}

View File

@ -12,6 +12,8 @@ locals {
compose_file = templatefile("${path.module}/templates/docker-compose.yml.tftpl", {})
default_conf = templatefile("${path.module}/templates/default.conf.tftpl", {
server_name = var.server_name
backend_host = var.backend_host
demos_backend_port = var.demos_backend_port
})
default_vcl = templatefile("${path.module}/templates/default.vcl.tftpl", {
backend_host = var.backend_host
@ -43,7 +45,7 @@ resource "null_resource" "edge_services" {
enable_letsencrypt = tostring(var.enable_letsencrypt)
letsencrypt_email = var.letsencrypt_email
letsencrypt_staging = tostring(var.letsencrypt_staging)
certbot_version = "1"
certbot_version = "2"
config_hash = local.config_hash
}
@ -140,8 +142,8 @@ if [ ! -s "$install_dir/certs/current.crt" ] || [ ! -s "$install_dir/certs/curre
fi
deploy_current_certificate() {
if [ ! -s "$install_dir/letsencrypt/live/$server_name/fullchain.pem" ] ||
[ ! -s "$install_dir/letsencrypt/live/$server_name/privkey.pem" ]; then
if ! sudo test -s "$install_dir/letsencrypt/live/$server_name/fullchain.pem" ||
! sudo test -s "$install_dir/letsencrypt/live/$server_name/privkey.pem"; then
return 1
fi
@ -198,7 +200,26 @@ TIMER_EOT
sudo systemctl enable --now homelab-edge-renew-certs.timer >/dev/null
}
check_edge_health() {
if ! curl "$@" >/dev/null; then
sudo docker compose ps || true
sudo docker compose logs --tail=80 nginx-dev || true
exit 1
fi
}
cleanup_legacy_edge_containers() {
local container
for container in nginx-dev haproxy-dev varnish-dev squid-dev; do
if sudo docker container inspect "$container" >/dev/null 2>&1; then
sudo docker rm -f "$container" >/dev/null
fi
done
}
cd "$install_dir"
cleanup_legacy_edge_containers
sudo docker compose pull
sudo docker compose up -d --remove-orphans
sudo docker compose ps
@ -214,7 +235,8 @@ if [ "$enable_letsencrypt" = "true" ]; then
staging_args="--staging"
fi
sudo docker run --rm \
certbot_log="$(mktemp)"
if ! sudo docker run --rm \
-v "$install_dir/letsencrypt:/etc/letsencrypt" \
-v "$install_dir/certbot/www:/var/www/certbot" \
"$certbot_image" certonly \
@ -226,15 +248,28 @@ if [ "$enable_letsencrypt" = "true" ]; then
--non-interactive \
--keep-until-expiring \
$email_args \
$staging_args
$staging_args > "$certbot_log" 2>&1; then
cat "$certbot_log"
if grep -Eq "Certificate not yet due for renewal|no action taken" "$certbot_log" &&
sudo test -s "$install_dir/letsencrypt/live/$server_name/fullchain.pem" &&
sudo test -s "$install_dir/letsencrypt/live/$server_name/privkey.pem"; then
echo "Using existing Let's Encrypt certificate for $server_name"
else
rm -f "$certbot_log"
exit 1
fi
else
cat "$certbot_log"
fi
rm -f "$certbot_log"
deploy_current_certificate
sudo docker compose exec -T nginx-dev nginx -s reload || sudo docker compose restart nginx-dev
install_renewal_timer
curl -fsS --connect-timeout 10 --resolve "$server_name:443:127.0.0.1" "https://$server_name/" >/dev/null
check_edge_health -fsS --connect-timeout 10 --resolve "$server_name:443:127.0.0.1" "https://$server_name/edge-health"
else
sudo systemctl disable --now homelab-edge-renew-certs.timer >/dev/null 2>&1 || true
curl -kfsS --connect-timeout 10 https://127.0.0.1/ >/dev/null
check_edge_health -kfsS --connect-timeout 10 https://127.0.0.1/edge-health
fi
EOT
]

View File

@ -70,6 +70,27 @@ server {
gzip_min_length 1000;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location = /edge-health {
access_log off;
return 204;
}
location ^~ /demo-apps/ {
limit_req zone=one burst=20 nodelay;
proxy_pass http://${backend_host}:${demos_backend_port}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_cache static_assets;
proxy_cache_valid 200 301 302 15m;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Nginx-Cache "$upstream_cache_status";
}
location ~* \.(css|js)$ {
proxy_pass http://haproxy_backend;
proxy_set_header Host $host;

View File

@ -1,7 +1,6 @@
services:
nginx-dev:
image: nginx:latest
container_name: nginx-dev
restart: unless-stopped
depends_on:
- haproxy-dev
@ -17,7 +16,6 @@ services:
haproxy-dev:
image: haproxy:alpine
container_name: haproxy-dev
restart: unless-stopped
depends_on:
- varnish-dev
@ -30,7 +28,6 @@ services:
varnish-dev:
image: varnish:fresh-alpine
container_name: varnish-dev
restart: unless-stopped
ports:
- "6081:80"
@ -39,7 +36,6 @@ services:
squid-dev:
image: ubuntu/squid:latest
container_name: squid-dev
restart: unless-stopped
ports:
- "3128:3128"

View File

@ -48,6 +48,11 @@ variable "backend_port" {
default = 30080
}
variable "demos_backend_port" {
type = number
default = 30081
}
variable "haproxy_stats_user" {
type = string
default = "admin"

298
lab.sh
View File

@ -138,6 +138,197 @@ website_registry_endpoint() {
printf '%s\n' "${image%%/*}"
}
demos_registry_endpoint() {
local image
image="$(awk '$1 == "image:" && $2 ~ /demos-static/ {print $2; exit}' "${REPO_ROOT}/apps/demos-static/web-app.yaml")"
if [[ -z "${image}" || "${image}" != */* ]]; then
echo "Could not determine demos registry endpoint from apps/demos-static/web-app.yaml" >&2
exit 1
fi
printf '%s\n' "${image%%/*}"
}
website_source_hash() {
(
cd "${REPO_ROOT}"
find apps/website -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}'
)
}
demos_source_hash() {
(
cd "${REPO_ROOT}"
find apps/demos-static -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}'
)
}
registry_image_exists() {
local registry_endpoint="$1"
local repository="$2"
local tag="$3"
local accept_header
if ! command -v curl >/dev/null 2>&1; then
return 1
fi
accept_header="application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json"
curl -fsS \
-H "Accept: ${accept_header}" \
"http://${registry_endpoint}/v2/${repository}/manifests/${tag}" >/dev/null
}
image_state_value() {
local state_file="$1"
local key="$2"
awk -F= -v key="${key}" '$1 == key {print substr($0, index($0, "=") + 1); exit}' "${state_file}" 2>/dev/null || true
}
website_image_is_current() {
local state_file="$1"
local source_hash="$2"
local platforms="$3"
local image_ref="$4"
local registry_endpoint="$5"
local saved_hash
local saved_platforms
local saved_image
[[ -f "${state_file}" ]] || return 1
saved_hash="$(image_state_value "${state_file}" source_hash)"
saved_platforms="$(image_state_value "${state_file}" platforms)"
saved_image="$(image_state_value "${state_file}" image)"
[[ "${saved_hash}" == "${source_hash}" ]] || return 1
[[ "${saved_platforms}" == "${platforms}" ]] || return 1
[[ "${saved_image}" == "${image_ref}" ]] || return 1
registry_image_exists "${registry_endpoint}" php-website latest
}
demos_image_is_current() {
local state_file="$1"
local source_hash="$2"
local platforms="$3"
local image_ref="$4"
local registry_endpoint="$5"
local saved_hash
local saved_platforms
local saved_image
[[ -f "${state_file}" ]] || return 1
saved_hash="$(image_state_value "${state_file}" source_hash)"
saved_platforms="$(image_state_value "${state_file}" platforms)"
saved_image="$(image_state_value "${state_file}" image)"
[[ "${saved_hash}" == "${source_hash}" ]] || return 1
[[ "${saved_platforms}" == "${platforms}" ]] || return 1
[[ "${saved_image}" == "${image_ref}" ]] || return 1
registry_image_exists "${registry_endpoint}" demos-static latest
}
write_website_image_state() {
local state_file="$1"
local source_hash="$2"
local platforms="$3"
local image_ref="$4"
mkdir -p "$(dirname "${state_file}")"
{
printf 'source_hash=%s\n' "${source_hash}"
printf 'platforms=%s\n' "${platforms}"
printf 'image=%s\n' "${image_ref}"
} > "${state_file}"
}
write_demos_image_state() {
local state_file="$1"
local source_hash="$2"
local platforms="$3"
local image_ref="$4"
mkdir -p "$(dirname "${state_file}")"
{
printf 'source_hash=%s\n' "${source_hash}"
printf 'platforms=%s\n' "${platforms}"
printf 'image=%s\n' "${image_ref}"
} > "${state_file}"
}
path_available_mb() {
local path="$1"
while [[ ! -e "${path}" && "${path}" != "/" ]]; do
path="$(dirname "${path}")"
done
df -Pm "${path}" | awk 'NR == 2 {print $4}'
}
docker_root_dir() {
docker info --format '{{.DockerRootDir}}' 2>/dev/null || printf '/var/lib/docker\n'
}
prune_unused_docker_build_data() {
docker buildx rm lab-builder 2>/dev/null || true
docker rm -f buildx_buildkit_lab-builder0 2>/dev/null || true
docker builder prune -af 2>/dev/null || true
docker system prune -af 2>/dev/null || true
}
ensure_docker_build_space() {
local docker_root
local free_mb
local min_free_mb
min_free_mb="${DOCKER_BUILD_MIN_FREE_MB:-4096}"
docker_root="$(docker_root_dir)"
free_mb="$(path_available_mb "${docker_root}")"
if (( free_mb >= min_free_mb )); then
return 0
fi
echo "Docker data root ${docker_root} has ${free_mb}MiB free; pruning unused Docker build data..."
prune_unused_docker_build_data
free_mb="$(path_available_mb "${docker_root}")"
if (( free_mb < min_free_mb )); then
echo "Docker data root ${docker_root} still has only ${free_mb}MiB free after cleanup." >&2
echo "Free space there or move Docker's data-root to a larger filesystem such as /home before building." >&2
echo "Override the threshold with DOCKER_BUILD_MIN_FREE_MB if this host can build with less space." >&2
exit 1
fi
}
prepare_buildx_builder() {
local registry_endpoint="$1"
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
cat <<EOF > "${BUILDX_CONFIG}"
[registry."${registry_endpoint}"]
http = true
insecure = true
[registry."127.0.0.1:30500"]
http = true
insecure = true
[registry."localhost:30500"]
http = true
insecure = true
EOF
docker buildx rm lab-builder 2>/dev/null || true
docker buildx create --name lab-builder --driver docker-container --driver-opt network=host --config "${BUILDX_CONFIG}" --use
docker buildx inspect --bootstrap
}
dump_argocd_debug() {
local app="$1"
@ -239,11 +430,32 @@ refresh_argocd_application() {
}
up() {
local buildx_builder_ready=false
local demos_image_built=false
local demos_image_ref
local demos_image_state_file
local demos_platforms
local demos_registry_endpoint
local demos_source_hash
local registry_endpoint
local website_image_built=false
local website_image_ref
local website_image_state_file
local website_platforms
local website_source_hash
require_debian_server "up"
registry_endpoint="$(website_registry_endpoint)"
demos_registry_endpoint="$(demos_registry_endpoint)"
demos_image_ref="${registry_endpoint}/demos-static:latest"
demos_image_state_file="${REPO_ROOT}/.lab/demos-static-image.state"
demos_platforms="${DEMOS_IMAGE_PLATFORMS:-linux/arm64}"
demos_source_hash="$(demos_source_hash)"
website_image_ref="${registry_endpoint}/php-website:latest"
website_image_state_file="${REPO_ROOT}/.lab/php-website-image.state"
website_platforms="${WEBSITE_IMAGE_PLATFORMS:-linux/arm64}"
website_source_hash="$(website_source_hash)"
export TF_VAR_registry_endpoint="${TF_VAR_registry_endpoint:-${registry_endpoint}}"
export TF_VAR_kubeconfig_path="${TF_VAR_kubeconfig_path:-${KUBECONFIG_PATH}}"
export KUBECONFIG="${TF_VAR_kubeconfig_path}"
@ -253,31 +465,19 @@ up() {
exit 1
fi
if [[ "${demos_registry_endpoint}" != "${registry_endpoint}" ]]; then
echo "apps/demos-static/web-app.yaml registry endpoint (${demos_registry_endpoint}) must match apps/website/web-app.yaml (${registry_endpoint})" >&2
exit 1
fi
echo "Deploying the homelab infrastructure..."
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
cat <<EOF > "${BUILDX_CONFIG}"
[registry."${registry_endpoint}"]
http = true
insecure = true
[registry."127.0.0.1:30500"]
http = true
insecure = true
[registry."localhost:30500"]
http = true
insecure = true
EOF
docker buildx rm lab-builder 2>/dev/null || true
docker buildx create --name lab-builder --driver docker-container --driver-opt network=host --config "${BUILDX_CONFIG}" --use
docker buildx inspect --bootstrap
run_tofu_stack "bootstrap/cluster"
run_tofu_stack "bootstrap/platform"
run_tofu_stack "bootstrap/apps"
refresh_argocd_application container-registry
refresh_argocd_application demos-static
refresh_argocd_application gitea
refresh_argocd_application website-production
@ -285,19 +485,77 @@ EOF
wait_for_namespaced_resource container-registry deployment local-registry container-registry 300
wait_for_deployment_ready container-registry local-registry container-registry 300
if website_image_is_current "${website_image_state_file}" "${website_source_hash}" "${website_platforms}" "${website_image_ref}" "${registry_endpoint}"; then
echo "Website image ${website_image_ref} is already current (${website_source_hash}); skipping build."
else
echo "Building website image ${website_image_ref} for ${website_platforms} (${website_source_hash})..."
ensure_docker_build_space
if [[ "${buildx_builder_ready}" != "true" ]]; then
prepare_buildx_builder "${registry_endpoint}"
buildx_builder_ready=true
fi
docker buildx build \
--network host \
--platform linux/amd64,linux/arm64 \
-t "${registry_endpoint}/php-website:latest" \
--platform "${website_platforms}" \
--provenance=false \
--sbom=false \
--label "dev.homelab.website.source-hash=${website_source_hash}" \
-t "${website_image_ref}" \
-f "${REPO_ROOT}/apps/website/Dockerfile" \
"${REPO_ROOT}/apps/website/" \
--push
website_image_built=true
fi
if demos_image_is_current "${demos_image_state_file}" "${demos_source_hash}" "${demos_platforms}" "${demos_image_ref}" "${registry_endpoint}"; then
echo "Demos image ${demos_image_ref} is already current (${demos_source_hash}); skipping build."
else
echo "Building demos image ${demos_image_ref} for ${demos_platforms} (${demos_source_hash})..."
ensure_docker_build_space
if [[ "${buildx_builder_ready}" != "true" ]]; then
prepare_buildx_builder "${registry_endpoint}"
buildx_builder_ready=true
fi
docker buildx build \
--network host \
--platform "${demos_platforms}" \
--provenance=false \
--sbom=false \
--label "dev.homelab.demos.source-hash=${demos_source_hash}" \
-t "${demos_image_ref}" \
-f "${REPO_ROOT}/apps/demos-static/Dockerfile" \
"${REPO_ROOT}/apps/demos-static/" \
--push
demos_image_built=true
fi
refresh_argocd_application website-production
wait_for_namespace website-production website-production 300
wait_for_namespaced_resource website-production deployment php-website-deployment website-production 300
if [[ "${website_image_built}" == "true" ]]; then
recreate_pods_for_selector website-production app=php-website website-production
else
echo "Skipping website pod restart because the image did not change."
fi
wait_for_deployment_ready website-production php-website-deployment website-production 300
if [[ "${website_image_built}" == "true" ]]; then
write_website_image_state "${website_image_state_file}" "${website_source_hash}" "${website_platforms}" "${website_image_ref}"
fi
refresh_argocd_application demos-static
wait_for_namespace demos-static demos-static 300
wait_for_namespaced_resource demos-static deployment demos-static demos-static 300
if [[ "${demos_image_built}" == "true" ]]; then
recreate_pods_for_selector demos-static app=demos-static demos-static
else
echo "Skipping demos pod restart because the image did not change."
fi
wait_for_deployment_ready demos-static demos-static demos-static 300
if [[ "${demos_image_built}" == "true" ]]; then
write_demos_image_state "${demos_image_state_file}" "${demos_source_hash}" "${demos_platforms}" "${demos_image_ref}"
fi
run_tofu_stack "bootstrap/edge"