adding demos
This commit is contained in:
parent
c7263f4673
commit
aca71f7cd3
|
|
@ -2,6 +2,7 @@
|
||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.backup
|
*.tfstate.backup
|
||||||
.terraform/
|
.terraform/
|
||||||
|
.lab/
|
||||||
|
|
||||||
# Ignore local archive dumps and backups
|
# Ignore local archive dumps and backups
|
||||||
*.tar
|
*.tar
|
||||||
|
|
@ -10,4 +11,3 @@ apps/gitea/gitea-docker-backup
|
||||||
|
|
||||||
# Ignore older source iterations
|
# Ignore older source iterations
|
||||||
*.old
|
*.old
|
||||||
*.terraform.lock.hcl
|
|
||||||
|
|
|
||||||
166
README.md
166
README.md
|
|
@ -3,6 +3,23 @@
|
||||||
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
|
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
|
||||||
Argo CD.
|
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
|
## Flow
|
||||||
|
|
||||||
1. `bootstrap/cluster`
|
1. `bootstrap/cluster`
|
||||||
|
|
@ -22,8 +39,8 @@ Argo CD.
|
||||||
|
|
||||||
3. `bootstrap/apps`
|
3. `bootstrap/apps`
|
||||||
- registers Argo CD Applications from the `applications` map
|
- registers Argo CD Applications from the `applications` map
|
||||||
- default apps are `container-registry`, `gitea`, and
|
- default apps are `container-registry`, `gitea`, `website-production`, and
|
||||||
`website-production`
|
`demos-static`
|
||||||
|
|
||||||
4. `bootstrap/edge`
|
4. `bootstrap/edge`
|
||||||
- connects to the OCI jump box
|
- connects to the OCI jump box
|
||||||
|
|
@ -31,6 +48,64 @@ Argo CD.
|
||||||
- obtains and renews Let's Encrypt certificates for the configured hostname
|
- obtains and renews Let's Encrypt certificates for the configured hostname
|
||||||
- runs the edge cache/proxy chain with Docker Compose
|
- 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
|
## Adding Nodes
|
||||||
|
|
||||||
Add entries to `bootstrap/cluster/variables.tf` or a `.tfvars` file:
|
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
|
Stateful apps currently pin retained local PVs to the `debian` node. Move or
|
||||||
duplicate those PV manifests when you want storage on another node.
|
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
|
The website and demos NodePorts are reachable from the OCI jump box through the
|
||||||
Tailscale interface. `bootstrap/cluster` installs a persistent
|
Raspberry Pi Tailscale interface. `bootstrap/cluster` installs a persistent
|
||||||
`homelab-tailscale-nodeport.service` on the configured worker to restore the
|
`homelab-tailscale-nodeport.service` on the configured worker to restore the
|
||||||
route, rp_filter settings, and iptables rules after reboot. Override the
|
route, rp_filter settings, and iptables rules after reboot. Override the
|
||||||
defaults through `tailscale_nodeport_access` when the jump-box IP, Pi Tailscale
|
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
|
```hcl
|
||||||
tailscale_nodeport_access = {
|
tailscale_nodeport_access = {
|
||||||
|
|
@ -66,6 +142,8 @@ tailscale_nodeport_access = {
|
||||||
node_port = 30080
|
node_port = 30080
|
||||||
target_port = 80
|
target_port = 80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tailscale_nodeport_extra_ports = [30081]
|
||||||
```
|
```
|
||||||
|
|
||||||
For `./lab.sh nuke`, set `WORKER_SSH_TARGETS` to a space-separated list of
|
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
|
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
|
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
|
or leave it empty to register without an email. Set
|
||||||
`TF_VAR_enable_letsencrypt=false` to keep using the temporary local certificate.
|
`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
|
volumes are declared explicitly as retained local PVs so a rebuilt cluster binds
|
||||||
back to the same host paths instead of creating fresh directories.
|
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
|
Keep the `.terraform.lock.hcl` files committed. They pin provider selections and
|
||||||
make bootstrap behavior reproducible across nodes and rebuilds.
|
make bootstrap behavior reproducible across nodes and rebuilds.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: demos-static
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.');
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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]);
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "Offline Traveler Converter",
|
||||||
|
"short_name": "Traveler Tools",
|
||||||
|
"start_url": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"theme_color": "#004085",
|
||||||
|
"icons": []
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<title>CV - <?php echo $text['name']; ?></title>
|
<title>CV - <?php echo $text['name']; ?></title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="cv-page cv-elegant">
|
||||||
|
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<div class="nav-left">Juvenal Diaz</div>
|
<div class="nav-left">Juvenal Diaz</div>
|
||||||
|
|
@ -25,22 +25,62 @@
|
||||||
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
|
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
|
||||||
<?php echo $text['nav_cv']; ?>
|
<?php echo $text['nav_cv']; ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="#"
|
<a href="blog.php?lang=<?php echo $lang; ?>"
|
||||||
data-translate data-key="nav_blog"
|
data-translate data-key="nav_blog"
|
||||||
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
|
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
|
||||||
<?php echo $text['nav_blog']; ?>
|
<?php echo $text['nav_blog']; ?>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</nav>
|
</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>
|
||||||
|
|
||||||
<h1><?php echo $text['name']; ?></h1>
|
<div class="container cv-container">
|
||||||
<p data-translate data-key="job_title"
|
|
||||||
data-en="<?php echo htmlspecialchars($en['job_title']); ?>">
|
<div class="cv-portrait-orbit" id="cv-portrait-orbit">
|
||||||
<?php echo $text['job_title']; ?>
|
<svg viewBox="0 0 260 260" aria-hidden="true">
|
||||||
</p>
|
<defs>
|
||||||
<p><?php echo $text['contacts']; ?></p>
|
<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"
|
<h2 data-translate data-key="cv_summary_title"
|
||||||
data-en="<?php echo htmlspecialchars($en['cv_summary_title']); ?>">
|
data-en="<?php echo htmlspecialchars($en['cv_summary_title']); ?>">
|
||||||
|
|
@ -128,6 +168,10 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const OTHER_PAGES = ['/index.php', '/blog.php'];
|
||||||
|
</script>
|
||||||
|
<script src="/cv-theme.js"></script>
|
||||||
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
|
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -25,11 +25,16 @@
|
||||||
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
|
data-en="<?php echo htmlspecialchars($en['nav_cv']); ?>">
|
||||||
<?php echo $text['nav_cv']; ?>
|
<?php echo $text['nav_cv']; ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="#"
|
<a href="blog.php?lang=<?php echo $lang; ?>"
|
||||||
data-translate data-key="nav_blog"
|
data-translate data-key="nav_blog"
|
||||||
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
|
data-en="<?php echo htmlspecialchars($en['nav_blog']); ?>">
|
||||||
<?php echo $text['nav_blog']; ?>
|
<?php echo $text['nav_blog']; ?>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -73,7 +78,7 @@
|
||||||
// Tell translation.js to also translate these pages in the background
|
// Tell translation.js to also translate these pages in the background
|
||||||
?>
|
?>
|
||||||
<script>
|
<script>
|
||||||
const OTHER_PAGES = ['/cv.php'];
|
const OTHER_PAGES = ['/cv.php', '/blog.php'];
|
||||||
</script>
|
</script>
|
||||||
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
|
<?php require_once __DIR__ . '/partials/translation_ui.php'; ?>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ return [
|
||||||
'nav_home' => 'Home',
|
'nav_home' => 'Home',
|
||||||
'nav_cv' => 'CV',
|
'nav_cv' => 'CV',
|
||||||
'nav_blog' => 'Blog',
|
'nav_blog' => 'Blog',
|
||||||
|
'nav_demos' => 'Demos',
|
||||||
|
|
||||||
// Index bio
|
// Index bio
|
||||||
'bio_intro' => 'I work in infrastructure and reliability, focusing on building systems that are stable, scalable, and easy to operate.',
|
'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 sections
|
||||||
'cv_summary_title' => 'Professional Summary',
|
'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_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',
|
'cv_employment_title' => 'Employment History / Activities',
|
||||||
|
|
||||||
|
|
@ -50,4 +55,66 @@ return [
|
||||||
'cv_job7_period' => 'February 2013 → August 2015',
|
'cv_job7_period' => 'February 2013 → August 2015',
|
||||||
'cv_job7_title' => 'Customer Support Agent – Teleperformance | Comcast',
|
'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.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ return [
|
||||||
'nav_home' => 'Nochan', // My home
|
'nav_home' => 'Nochan', // My home
|
||||||
'nav_cv' => 'Notlahcuilol', // My document/record
|
'nav_cv' => 'Notlahcuilol', // My document/record
|
||||||
'nav_blog' => 'Notlahtol', // My words
|
'nav_blog' => 'Notlahtol', // My words
|
||||||
|
'nav_demos' => 'Tlayeyecoliztli',
|
||||||
|
|
||||||
// Index bio
|
// Index bio
|
||||||
'bio_intro' => 'Nitlatequitia ipan tlatecpanaliztli ihuan tlayeyecoliztli, niquitta in quenin tiquitasque tlapatlaliztli tlayecoliztli tlamantli nemiztli.',
|
'bio_intro' => 'Nitlatequitia ipan tlatecpanaliztli ihuan tlayeyecoliztli, niquitta in quenin tiquitasque tlapatlaliztli tlayecoliztli tlamantli nemiztli.',
|
||||||
|
|
@ -31,6 +32,10 @@ return [
|
||||||
// CV sections
|
// CV sections
|
||||||
'cv_summary_title' => 'Notequitl Tlahcuilolli',
|
'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_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',
|
'cv_employment_title' => 'Notequitl Tlahcuilolli / Tlatequipanoliztli',
|
||||||
|
|
||||||
|
|
@ -61,4 +66,66 @@ return [
|
||||||
'cv_job7_period' => 'Febrero 2013 → Agosto 2015',
|
'cv_job7_period' => 'Febrero 2013 → Agosto 2015',
|
||||||
'cv_job7_title' => 'Tlapalehuiani Tlacame – Teleperformance | Comcast',
|
'cv_job7_title' => 'Tlapalehuiani Tlacame – Teleperformance | Comcast',
|
||||||
'cv_job7_desc' => 'Nitlapalehua tlacame ipan US inic cable, tepoztli, ihuan tlahtoa tlamantli.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ if (!file_exists($file)) {
|
||||||
$file = __DIR__ . "/lang/nah.php";
|
$file = __DIR__ . "/lang/nah.php";
|
||||||
}
|
}
|
||||||
|
|
||||||
$text = include $file;
|
|
||||||
|
|
||||||
// Always load English as translation source
|
// Always load English as translation source
|
||||||
$en = include __DIR__ . '/lang/en.php';
|
$en = include __DIR__ . '/lang/en.php';
|
||||||
|
$text = array_replace($en, include $file);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<a href="index.php?lang=<?php echo $lang; ?>">Home</a>
|
<a href="index.php?lang=<?php echo $lang; ?>">Home</a>
|
||||||
<a href="cv.php?lang=<?php echo $lang; ?>">CV</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>
|
<?php foreach ($availableLangs as $code): ?>
|
||||||
<a href="?lang=es">ES</a>
|
<a href="<?php echo basename($_SERVER['PHP_SELF']); ?>?lang=<?php echo $code; ?>"><?php echo strtoupper($code); ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -203,3 +203,592 @@ body {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,5 +52,14 @@ variable "applications" {
|
||||||
self_heal = true
|
self_heal = true
|
||||||
create_namespace = 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,12 +298,12 @@ resource "null_resource" "kubeadm_worker" {
|
||||||
registry_config_version = "6"
|
registry_config_version = "6"
|
||||||
node_dns_servers = join(" ", var.node_dns_servers)
|
node_dns_servers = join(" ", var.node_dns_servers)
|
||||||
persistent_volume_dirs = join(",", var.persistent_volume_dirs)
|
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_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_peer_ip = var.tailscale_nodeport_access.peer_ip
|
||||||
tailscale_nodeport_node_tailscale_ip = var.tailscale_nodeport_access.node_tailscale_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_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)
|
tailscale_nodeport_target_port = tostring(var.tailscale_nodeport_access.target_port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,7 +530,7 @@ configure_tailscale_nodeport_access() {
|
||||||
local peer_ip="$2"
|
local peer_ip="$2"
|
||||||
local node_tailscale_ip="$3"
|
local node_tailscale_ip="$3"
|
||||||
local pod_cidr="$4"
|
local pod_cidr="$4"
|
||||||
local node_port="$5"
|
local node_ports="$5"
|
||||||
local target_port="$6"
|
local target_port="$6"
|
||||||
|
|
||||||
if [ "$enabled" != "true" ]; then
|
if [ "$enabled" != "true" ]; then
|
||||||
|
|
@ -557,8 +557,10 @@ fi
|
||||||
|
|
||||||
ip route replace "$peer_ip/32" dev tailscale0 src "$node_tailscale_ip"
|
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 ||
|
for node_port in $node_ports; do
|
||||||
iptables -I INPUT 1 -i tailscale0 -p tcp --dport "$node_port" -j ACCEPT
|
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 -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 -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 ||
|
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_peer_ip}" \
|
||||||
"${self.triggers.tailscale_nodeport_node_tailscale_ip}" \
|
"${self.triggers.tailscale_nodeport_node_tailscale_ip}" \
|
||||||
"${self.triggers.tailscale_nodeport_pod_cidr}" \
|
"${self.triggers.tailscale_nodeport_pod_cidr}" \
|
||||||
"${self.triggers.tailscale_nodeport_node_port}" \
|
"${self.triggers.tailscale_nodeport_node_ports}" \
|
||||||
"${self.triggers.tailscale_nodeport_target_port}"
|
"${self.triggers.tailscale_nodeport_target_port}"
|
||||||
|
|
||||||
configure_containerd_registry "${self.triggers.registry_endpoint}"
|
configure_containerd_registry "${self.triggers.registry_endpoint}"
|
||||||
|
|
@ -636,4 +638,3 @@ output "kubeconfig_path" {
|
||||||
output "pod_network_cidr" {
|
output "pod_network_cidr" {
|
||||||
value = var.pod_network_cidr
|
value = var.pod_network_cidr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,3 +83,8 @@ variable "tailscale_nodeport_access" {
|
||||||
target_port = 80
|
target_port = 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "tailscale_nodeport_extra_ports" {
|
||||||
|
type = list(number)
|
||||||
|
default = [30081]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ terraform {
|
||||||
locals {
|
locals {
|
||||||
compose_file = templatefile("${path.module}/templates/docker-compose.yml.tftpl", {})
|
compose_file = templatefile("${path.module}/templates/docker-compose.yml.tftpl", {})
|
||||||
default_conf = templatefile("${path.module}/templates/default.conf.tftpl", {
|
default_conf = templatefile("${path.module}/templates/default.conf.tftpl", {
|
||||||
server_name = var.server_name
|
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", {
|
default_vcl = templatefile("${path.module}/templates/default.vcl.tftpl", {
|
||||||
backend_host = var.backend_host
|
backend_host = var.backend_host
|
||||||
|
|
@ -43,7 +45,7 @@ resource "null_resource" "edge_services" {
|
||||||
enable_letsencrypt = tostring(var.enable_letsencrypt)
|
enable_letsencrypt = tostring(var.enable_letsencrypt)
|
||||||
letsencrypt_email = var.letsencrypt_email
|
letsencrypt_email = var.letsencrypt_email
|
||||||
letsencrypt_staging = tostring(var.letsencrypt_staging)
|
letsencrypt_staging = tostring(var.letsencrypt_staging)
|
||||||
certbot_version = "1"
|
certbot_version = "2"
|
||||||
config_hash = local.config_hash
|
config_hash = local.config_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,8 +142,8 @@ if [ ! -s "$install_dir/certs/current.crt" ] || [ ! -s "$install_dir/certs/curre
|
||||||
fi
|
fi
|
||||||
|
|
||||||
deploy_current_certificate() {
|
deploy_current_certificate() {
|
||||||
if [ ! -s "$install_dir/letsencrypt/live/$server_name/fullchain.pem" ] ||
|
if ! sudo test -s "$install_dir/letsencrypt/live/$server_name/fullchain.pem" ||
|
||||||
[ ! -s "$install_dir/letsencrypt/live/$server_name/privkey.pem" ]; then
|
! sudo test -s "$install_dir/letsencrypt/live/$server_name/privkey.pem"; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -198,7 +200,26 @@ TIMER_EOT
|
||||||
sudo systemctl enable --now homelab-edge-renew-certs.timer >/dev/null
|
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"
|
cd "$install_dir"
|
||||||
|
cleanup_legacy_edge_containers
|
||||||
sudo docker compose pull
|
sudo docker compose pull
|
||||||
sudo docker compose up -d --remove-orphans
|
sudo docker compose up -d --remove-orphans
|
||||||
sudo docker compose ps
|
sudo docker compose ps
|
||||||
|
|
@ -214,27 +235,41 @@ if [ "$enable_letsencrypt" = "true" ]; then
|
||||||
staging_args="--staging"
|
staging_args="--staging"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo docker run --rm \
|
certbot_log="$(mktemp)"
|
||||||
-v "$install_dir/letsencrypt:/etc/letsencrypt" \
|
if ! sudo docker run --rm \
|
||||||
-v "$install_dir/certbot/www:/var/www/certbot" \
|
-v "$install_dir/letsencrypt:/etc/letsencrypt" \
|
||||||
"$certbot_image" certonly \
|
-v "$install_dir/certbot/www:/var/www/certbot" \
|
||||||
--webroot \
|
"$certbot_image" certonly \
|
||||||
-w /var/www/certbot \
|
--webroot \
|
||||||
-d "$server_name" \
|
-w /var/www/certbot \
|
||||||
--preferred-challenges http \
|
-d "$server_name" \
|
||||||
--agree-tos \
|
--preferred-challenges http \
|
||||||
--non-interactive \
|
--agree-tos \
|
||||||
--keep-until-expiring \
|
--non-interactive \
|
||||||
$email_args \
|
--keep-until-expiring \
|
||||||
$staging_args
|
$email_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
|
deploy_current_certificate
|
||||||
sudo docker compose exec -T nginx-dev nginx -s reload || sudo docker compose restart nginx-dev
|
sudo docker compose exec -T nginx-dev nginx -s reload || sudo docker compose restart nginx-dev
|
||||||
install_renewal_timer
|
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
|
else
|
||||||
sudo systemctl disable --now homelab-edge-renew-certs.timer >/dev/null 2>&1 || true
|
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
|
fi
|
||||||
EOT
|
EOT
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,27 @@ server {
|
||||||
gzip_min_length 1000;
|
gzip_min_length 1000;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
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)$ {
|
location ~* \.(css|js)$ {
|
||||||
proxy_pass http://haproxy_backend;
|
proxy_pass http://haproxy_backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
services:
|
services:
|
||||||
nginx-dev:
|
nginx-dev:
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
container_name: nginx-dev
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- haproxy-dev
|
- haproxy-dev
|
||||||
|
|
@ -17,7 +16,6 @@ services:
|
||||||
|
|
||||||
haproxy-dev:
|
haproxy-dev:
|
||||||
image: haproxy:alpine
|
image: haproxy:alpine
|
||||||
container_name: haproxy-dev
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- varnish-dev
|
- varnish-dev
|
||||||
|
|
@ -30,7 +28,6 @@ services:
|
||||||
|
|
||||||
varnish-dev:
|
varnish-dev:
|
||||||
image: varnish:fresh-alpine
|
image: varnish:fresh-alpine
|
||||||
container_name: varnish-dev
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6081:80"
|
- "6081:80"
|
||||||
|
|
@ -39,7 +36,6 @@ services:
|
||||||
|
|
||||||
squid-dev:
|
squid-dev:
|
||||||
image: ubuntu/squid:latest
|
image: ubuntu/squid:latest
|
||||||
container_name: squid-dev
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3128:3128"
|
- "3128:3128"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ variable "backend_port" {
|
||||||
default = 30080
|
default = 30080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "demos_backend_port" {
|
||||||
|
type = number
|
||||||
|
default = 30081
|
||||||
|
}
|
||||||
|
|
||||||
variable "haproxy_stats_user" {
|
variable "haproxy_stats_user" {
|
||||||
type = string
|
type = string
|
||||||
default = "admin"
|
default = "admin"
|
||||||
|
|
|
||||||
310
lab.sh
310
lab.sh
|
|
@ -138,6 +138,197 @@ website_registry_endpoint() {
|
||||||
printf '%s\n' "${image%%/*}"
|
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() {
|
dump_argocd_debug() {
|
||||||
local app="$1"
|
local app="$1"
|
||||||
|
|
||||||
|
|
@ -239,11 +430,32 @@ refresh_argocd_application() {
|
||||||
}
|
}
|
||||||
|
|
||||||
up() {
|
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 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"
|
require_debian_server "up"
|
||||||
|
|
||||||
registry_endpoint="$(website_registry_endpoint)"
|
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_registry_endpoint="${TF_VAR_registry_endpoint:-${registry_endpoint}}"
|
||||||
export TF_VAR_kubeconfig_path="${TF_VAR_kubeconfig_path:-${KUBECONFIG_PATH}}"
|
export TF_VAR_kubeconfig_path="${TF_VAR_kubeconfig_path:-${KUBECONFIG_PATH}}"
|
||||||
export KUBECONFIG="${TF_VAR_kubeconfig_path}"
|
export KUBECONFIG="${TF_VAR_kubeconfig_path}"
|
||||||
|
|
@ -253,31 +465,19 @@ up() {
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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..."
|
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/cluster"
|
||||||
run_tofu_stack "bootstrap/platform"
|
run_tofu_stack "bootstrap/platform"
|
||||||
run_tofu_stack "bootstrap/apps"
|
run_tofu_stack "bootstrap/apps"
|
||||||
|
|
||||||
refresh_argocd_application container-registry
|
refresh_argocd_application container-registry
|
||||||
|
refresh_argocd_application demos-static
|
||||||
refresh_argocd_application gitea
|
refresh_argocd_application gitea
|
||||||
refresh_argocd_application website-production
|
refresh_argocd_application website-production
|
||||||
|
|
||||||
|
|
@ -285,19 +485,77 @@ EOF
|
||||||
wait_for_namespaced_resource container-registry deployment local-registry container-registry 300
|
wait_for_namespaced_resource container-registry deployment local-registry container-registry 300
|
||||||
wait_for_deployment_ready container-registry local-registry container-registry 300
|
wait_for_deployment_ready container-registry local-registry container-registry 300
|
||||||
|
|
||||||
docker buildx build \
|
if website_image_is_current "${website_image_state_file}" "${website_source_hash}" "${website_platforms}" "${website_image_ref}" "${registry_endpoint}"; then
|
||||||
--network host \
|
echo "Website image ${website_image_ref} is already current (${website_source_hash}); skipping build."
|
||||||
--platform linux/amd64,linux/arm64 \
|
else
|
||||||
-t "${registry_endpoint}/php-website:latest" \
|
echo "Building website image ${website_image_ref} for ${website_platforms} (${website_source_hash})..."
|
||||||
-f "${REPO_ROOT}/apps/website/Dockerfile" \
|
ensure_docker_build_space
|
||||||
"${REPO_ROOT}/apps/website/" \
|
if [[ "${buildx_builder_ready}" != "true" ]]; then
|
||||||
--push
|
prepare_buildx_builder "${registry_endpoint}"
|
||||||
|
buildx_builder_ready=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--network host \
|
||||||
|
--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
|
refresh_argocd_application website-production
|
||||||
wait_for_namespace website-production website-production 300
|
wait_for_namespace website-production website-production 300
|
||||||
wait_for_namespaced_resource website-production deployment php-website-deployment website-production 300
|
wait_for_namespaced_resource website-production deployment php-website-deployment website-production 300
|
||||||
recreate_pods_for_selector website-production app=php-website website-production
|
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
|
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"
|
run_tofu_stack "bootstrap/edge"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue