diff --git a/.gitignore b/.gitignore
index c2fdd15..9dde607 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 9d6c969..d63e7a8 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/apps/demos-static/Dockerfile b/apps/demos-static/Dockerfile
new file mode 100644
index 0000000..4372ba7
--- /dev/null
+++ b/apps/demos-static/Dockerfile
@@ -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
diff --git a/apps/demos-static/namespace.yaml b/apps/demos-static/namespace.yaml
new file mode 100644
index 0000000..791bdd1
--- /dev/null
+++ b/apps/demos-static/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: demos-static
diff --git a/apps/demos-static/nginx.conf b/apps/demos-static/nginx.conf
new file mode 100644
index 0000000..484b717
--- /dev/null
+++ b/apps/demos-static/nginx.conf
@@ -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;
+ }
+}
diff --git a/apps/demos-static/public/architecture-simulator/architecture-simulator.js b/apps/demos-static/public/architecture-simulator/architecture-simulator.js
new file mode 100644
index 0000000..a50b8e5
--- /dev/null
+++ b/apps/demos-static/public/architecture-simulator/architecture-simulator.js
@@ -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();
diff --git a/apps/demos-static/public/architecture-simulator/index.html b/apps/demos-static/public/architecture-simulator/index.html
new file mode 100644
index 0000000..0b30418
--- /dev/null
+++ b/apps/demos-static/public/architecture-simulator/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+ Architecture Simulator
+
+
+
+
+
+
Demo 04
Interactive System Architecture Simulator
Click-driven load, failure, and self-healing simulation for a tiny web stack.
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.
@@ -73,7 +78,7 @@
// Tell translation.js to also translate these pages in the background
?>
diff --git a/apps/website/lang/en.php b/apps/website/lang/en.php
index c80d0d5..c96716c 100644
--- a/apps/website/lang/en.php
+++ b/apps/website/lang/en.php
@@ -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.',
];
diff --git a/apps/website/lang/nah.php b/apps/website/lang/nah.php
index a7fa5a7..533be97 100644
--- a/apps/website/lang/nah.php
+++ b/apps/website/lang/nah.php
@@ -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.',
];
diff --git a/apps/website/lang_helper.php b/apps/website/lang_helper.php
index 50f85e9..43d45a8 100644
--- a/apps/website/lang_helper.php
+++ b/apps/website/lang_helper.php
@@ -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);
diff --git a/apps/website/partials/header.php b/apps/website/partials/header.php
index 6ea4d7f..e49f375 100644
--- a/apps/website/partials/header.php
+++ b/apps/website/partials/header.php
@@ -3,9 +3,11 @@