diff --git a/CLAUDE.md b/CLAUDE.md index 8bbc6db..b771093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, ha ├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094) ├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server ├── [COMPOSE] Farm Agent (:8889) -├── [COMPOSE] Rongorongo (:8181) -- static HTML/JS decipherment engine (Anthropic API direct from browser) +├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm. ├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04 ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB @@ -164,7 +164,7 @@ compose/ # Standalone Compose definitions (deployed per-node) - AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0) - Farm Manager: :8888 (dashboard + API) - Farm Agent: :8889 -- Rongorongo: :8181 (web at rongorongo.hobbs.farm) +- Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden). - ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04 **hf-pdocker-02 (192.168.86.100):** diff --git a/compose/hf-pdocker-01/rongorongo/backend/Dockerfile b/compose/hf-pdocker-01/rongorongo/backend/Dockerfile new file mode 100644 index 0000000..860c433 --- /dev/null +++ b/compose/hf-pdocker-01/rongorongo/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" || exit 1 + +CMD ["gunicorn", "-w", "2", "-t", "60", "-b", "0.0.0.0:8000", "app:app"] diff --git a/compose/hf-pdocker-01/rongorongo/backend/app.py b/compose/hf-pdocker-01/rongorongo/backend/app.py new file mode 100644 index 0000000..b9a95af --- /dev/null +++ b/compose/hf-pdocker-01/rongorongo/backend/app.py @@ -0,0 +1,139 @@ +""" +Rongorongo state-sync backend. + +Single-user. Stores state as one JSON file and images as content-addressed +files on disk. No DB, no auth — auth is enforced by Traefik basicAuth in +front of the whole app. +""" +import hashlib +import json +import mimetypes +import os +import tempfile +from pathlib import Path + +from flask import Flask, abort, jsonify, request, send_file + +DATA_DIR = Path(os.environ.get("RONGORONGO_DATA_DIR", "/data")) +STATE_FILE = DATA_DIR / "state.json" +IMAGES_DIR = DATA_DIR / "images" +MAX_IMAGE_BYTES = 25 * 1024 * 1024 # 25 MB per image + +DATA_DIR.mkdir(parents=True, exist_ok=True) +IMAGES_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = MAX_IMAGE_BYTES + 1024 + + +def _atomic_write(path: Path, data: bytes) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".part") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + +def _ext_for(mime: str) -> str: + if not mime: + return ".bin" + guess = mimetypes.guess_extension(mime) or ".bin" + # mimetypes returns .jpe for image/jpeg on some systems + return ".jpg" if guess == ".jpe" else guess + + +@app.get("/health") +def health(): + return ("ok\n", 200, {"Content-Type": "text/plain"}) + + +@app.get("/api/health") +def api_health(): + return ("ok\n", 200, {"Content-Type": "text/plain"}) + + +@app.get("/api/state") +def get_state(): + if not STATE_FILE.exists(): + return jsonify( + { + "images": [], + "glyphs": [], + "anchors": [], + "analyses": [], + "feasibilityRuns": [], + "confidenceHistory": {}, + } + ) + return send_file(STATE_FILE, mimetype="application/json") + + +@app.put("/api/state") +def put_state(): + if not request.is_json: + abort(415, description="expected application/json") + payload = request.get_json(silent=False) + if not isinstance(payload, dict): + abort(400, description="state must be a JSON object") + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + _atomic_write(STATE_FILE, body) + return jsonify({"ok": True, "bytes": len(body)}) + + +def _find_image(image_hash: str) -> Path | None: + for p in IMAGES_DIR.glob(f"{image_hash}.*"): + return p + return None + + +@app.post("/api/images") +def upload_image(): + f = request.files.get("file") + if f is None or f.filename == "": + abort(400, description="missing file") + data = f.read() + if len(data) == 0: + abort(400, description="empty file") + if len(data) > MAX_IMAGE_BYTES: + abort(413, description="image too large") + digest = hashlib.sha256(data).hexdigest() + mime = f.mimetype or "application/octet-stream" + existing = _find_image(digest) + if existing is None: + target = IMAGES_DIR / f"{digest}{_ext_for(mime)}" + _atomic_write(target, data) + return jsonify( + {"hash": digest, "name": f.filename, "mimeType": mime, "size": len(data)} + ) + + +@app.get("/api/images/") +def get_image(image_hash: str): + if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64: + abort(400, description="invalid hash") + p = _find_image(image_hash) + if p is None: + abort(404) + mime, _ = mimetypes.guess_type(str(p)) + return send_file(p, mimetype=mime or "application/octet-stream") + + +@app.delete("/api/images/") +def delete_image(image_hash: str): + if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64: + abort(400, description="invalid hash") + p = _find_image(image_hash) + if p is None: + return ("", 204) + p.unlink() + return ("", 204) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/compose/hf-pdocker-01/rongorongo/backend/requirements.txt b/compose/hf-pdocker-01/rongorongo/backend/requirements.txt new file mode 100644 index 0000000..b082f70 --- /dev/null +++ b/compose/hf-pdocker-01/rongorongo/backend/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.3 +gunicorn==23.0.0 diff --git a/compose/hf-pdocker-01/rongorongo/docker-compose.yml b/compose/hf-pdocker-01/rongorongo/docker-compose.yml index 34c7285..b181779 100644 --- a/compose/hf-pdocker-01/rongorongo/docker-compose.yml +++ b/compose/hf-pdocker-01/rongorongo/docker-compose.yml @@ -1,17 +1,19 @@ services: - rongorongo: + rongorongo-api: build: - context: . + context: ./backend dockerfile: Dockerfile - image: rongorongo:latest - container_name: rongorongo + image: rongorongo-api:latest + container_name: rongorongo-api restart: unless-stopped - ports: - - "8181:80" - mem_limit: 128M - memswap_limit: 128M + networks: + - rongorongo + volumes: + - /mnt/docker-data/configs/rongorongo/data:/data + mem_limit: 256M + memswap_limit: 256M healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost/health"] + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"] interval: 30s timeout: 5s retries: 3 @@ -23,3 +25,36 @@ services: options: max-size: "10m" max-file: "3" + + rongorongo-web: + build: + context: ./frontend + dockerfile: Dockerfile + image: rongorongo-web:latest + container_name: rongorongo-web + restart: unless-stopped + depends_on: + - rongorongo-api + networks: + - rongorongo + ports: + - "8181:80" + mem_limit: 128M + memswap_limit: 128M + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + rongorongo: + driver: bridge diff --git a/compose/hf-pdocker-01/rongorongo/Dockerfile b/compose/hf-pdocker-01/rongorongo/frontend/Dockerfile similarity index 60% rename from compose/hf-pdocker-01/rongorongo/Dockerfile rename to compose/hf-pdocker-01/rongorongo/frontend/Dockerfile index 5cdeb19..83ce410 100644 --- a/compose/hf-pdocker-01/rongorongo/Dockerfile +++ b/compose/hf-pdocker-01/rongorongo/frontend/Dockerfile @@ -2,11 +2,12 @@ FROM nginx:1.25-alpine RUN rm -rf /usr/share/nginx/html/* -COPY frontend/index.html /usr/share/nginx/html/index.html +COPY index.html /usr/share/nginx/html/index.html COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN chmod 644 /usr/share/nginx/html/index.html HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://localhost/health || exit 1 + CMD wget -qO- http://127.0.0.1/health || exit 1 EXPOSE 80 diff --git a/compose/hf-pdocker-01/rongorongo/frontend/index.html b/compose/hf-pdocker-01/rongorongo/frontend/index.html index 3a8b08c..58792ac 100644 --- a/compose/hf-pdocker-01/rongorongo/frontend/index.html +++ b/compose/hf-pdocker-01/rongorongo/frontend/index.html @@ -727,6 +727,7 @@ select option { background: var(--surface); } @@ -1167,32 +1168,107 @@ const S = { confidenceHistory: {}, // anchorId -> [{ts, score}] }; -// ── PERSISTENCE ──────────────────────────── +// ── PERSISTENCE (server-synced via /api/state) ──────────────────────── +let _saveTimer = null; +let _saveSuppressed = false; // true while load() is rehydrating +let _loaded = false; // true after first successful load + function save() { + if (_saveSuppressed || !_loaded) return; + clearTimeout(_saveTimer); + _saveTimer = setTimeout(_flushSave, 400); +} + +async function _flushSave() { try { const snap = { + images: S.images.map(i => ({ name: i.name, mimeType: i.mimeType, hash: i.hash })), glyphs: S.glyphs, anchors: S.anchors, analyses: S.analyses.slice(0,10), feasibilityRuns: S.feasibilityRuns.slice(0,5), confidenceHistory: S.confidenceHistory, }; - localStorage.setItem('rongo_state', JSON.stringify(snap)); - } catch(e) {} + const r = await fetch('/api/state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snap), + }); + if (!r.ok) throw new Error('save failed: HTTP ' + r.status); + setSyncStatus('synced'); + } catch (e) { + setSyncStatus('error', e.message); + } } -function load() { +function setSyncStatus(state, detail) { + const el = document.getElementById('syncStatus'); + if (!el) return; + if (state === 'syncing') { el.textContent = '⟳ syncing…'; el.style.color = '#888'; } + else if (state === 'synced') { el.textContent = '● synced'; el.style.color = '#4caf50'; } + else if (state === 'error') { el.textContent = '✕ sync error'; el.style.color = '#e57373'; el.title = detail||''; } + else if (state === 'loading') { el.textContent = '⟳ loading…'; el.style.color = '#888'; } +} + +async function _hydrateImage(meta) { + // Fetch image bytes by hash and rebuild {dataUrl, base64} in memory. + const r = await fetch('/api/images/' + encodeURIComponent(meta.hash)); + if (!r.ok) throw new Error('image fetch failed: ' + meta.hash + ' HTTP ' + r.status); + const blob = await r.blob(); + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(blob); + }); + return { + name: meta.name, + mimeType: meta.mimeType || blob.type || 'image/jpeg', + hash: meta.hash, + dataUrl, + base64: dataUrl.split(',')[1], + }; +} + +async function load() { + setSyncStatus('loading'); + _saveSuppressed = true; + let imgMetas = []; try { - const raw = localStorage.getItem('rongo_state'); - if (!raw) return; - const snap = JSON.parse(raw); + const r = await fetch('/api/state'); + if (!r.ok) throw new Error('GET /api/state HTTP ' + r.status); + const snap = await r.json(); S.glyphs = snap.glyphs || []; S.anchors = snap.anchors || []; S.analyses = snap.analyses || []; S.feasibilityRuns = snap.feasibilityRuns || []; S.confidenceHistory = snap.confidenceHistory || {}; + imgMetas = snap.images || []; + S.images = imgMetas.map(m => ({ + name: m.name, mimeType: m.mimeType, hash: m.hash, + dataUrl: '', base64: '', + })); + renderImageStrip(); refreshAll(); - } catch(e) {} + setSyncStatus('synced'); + } catch (e) { + setSyncStatus('error', e.message); + console.error('load failed', e); + } finally { + _saveSuppressed = false; + _loaded = true; + } + // Hydrate image bytes in parallel; render thumbs as each arrives. + // Don't block save() on this — save snapshots only metadata anyway. + await Promise.all(imgMetas.map(async (meta, idx) => { + try { + const full = await _hydrateImage(meta); + Object.assign(S.images[idx], full); + renderImageStrip(); + } catch (e) { + console.error('image hydrate failed', meta, e); + } + })); } function saveApiKey(v) { @@ -1290,17 +1366,35 @@ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('over uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('over'); handleFiles([...e.dataTransfer.files].filter(f=>f.type.startsWith('image/'))); }); fileInput.addEventListener('change', e => handleFiles([...e.target.files])); -function handleFiles(files) { - files.forEach(f => { - const fr = new FileReader(); - fr.onload = e => { - S.images.push({ name: f.name, dataUrl: e.target.result, base64: e.target.result.split(',')[1], mimeType: f.type||'image/jpeg' }); +async function handleFiles(files) { + for (const f of files) { + try { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = e => res(e.target.result); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(f); + }); + const fd = new FormData(); + fd.append('file', f, f.name); + const r = await fetch('/api/images', { method: 'POST', body: fd }); + if (!r.ok) throw new Error('upload failed: HTTP ' + r.status); + const meta = await r.json(); + S.images.push({ + name: f.name, + mimeType: f.type || meta.mimeType || 'image/jpeg', + hash: meta.hash, + dataUrl, + base64: dataUrl.split(',')[1], + }); renderImageStrip(); refreshAll(); toast(`Loaded ${f.name}`); - }; - fr.readAsDataURL(f); - }); + } catch (e) { + console.error('upload failed', f.name, e); + toast(`Upload failed: ${f.name}`); + } + } } function renderImageStrip() { @@ -1312,10 +1406,17 @@ function renderImageStrip() { `).join(''); } -function removeImage(i) { +async function removeImage(i) { + const img = S.images[i]; + if (!img) return; S.images.splice(i,1); renderImageStrip(); refreshAll(); + if (img.hash) { + // best-effort backend delete; safe to ignore failures (state is already saved) + try { await fetch('/api/images/' + encodeURIComponent(img.hash), { method: 'DELETE' }); } + catch(e) { console.warn('image delete failed on server', img.hash, e); } + } } function refreshAll() { @@ -2032,12 +2133,17 @@ function downloadReport() { toast('Session exported'); } -function clearSession() { +async function clearSession() { if (!confirm('Clear all session data? This cannot be undone.')) return; + const oldHashes = S.images.map(i => i.hash).filter(Boolean); S.images=[]; S.glyphs=[]; S.anchors=[]; S.analyses=[]; S.feasibilityRuns=[]; S.confidenceHistory={}; - try { localStorage.removeItem('rongo_state'); } catch(e){} refreshAll(); renderImageStrip(); + // Force-flush an empty state immediately, then drop image blobs server-side. + await _flushSave(); + await Promise.all(oldHashes.map(h => + fetch('/api/images/' + encodeURIComponent(h), { method: 'DELETE' }).catch(()=>{}) + )); toast('Session cleared'); } diff --git a/compose/hf-pdocker-01/rongorongo/nginx.conf b/compose/hf-pdocker-01/rongorongo/frontend/nginx.conf similarity index 67% rename from compose/hf-pdocker-01/rongorongo/nginx.conf rename to compose/hf-pdocker-01/rongorongo/frontend/nginx.conf index 687c7d6..5b7f1d5 100644 --- a/compose/hf-pdocker-01/rongorongo/nginx.conf +++ b/compose/hf-pdocker-01/rongorongo/frontend/nginx.conf @@ -5,6 +5,8 @@ server { root /usr/share/nginx/html; index index.html; + client_max_body_size 30m; + add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; @@ -14,7 +16,7 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always; gzip on; - gzip_types text/html text/css application/javascript; + gzip_types text/html text/css application/javascript application/json; gzip_min_length 1024; location /health { @@ -23,6 +25,19 @@ server { add_header Content-Type text/plain; } + location /api/ { + resolver 127.0.0.11 valid=10s; + set $backend "http://rongorongo-api:8000"; + proxy_pass $backend; + proxy_http_version 1.1; + 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 $scheme; + proxy_request_buffering off; + proxy_read_timeout 60s; + } + location / { try_files $uri $uri/ /index.html; expires -1; diff --git a/stacks/monitoring/prometheus.yml b/stacks/monitoring/prometheus.yml index 7a22470..88d07a4 100644 --- a/stacks/monitoring/prometheus.yml +++ b/stacks/monitoring/prometheus.yml @@ -70,6 +70,7 @@ scrape_configs: - http://192.168.86.192:9093 # Alertmanager - http://192.168.86.192:18795 # Spirit (infra ops agent) - http://192.168.86.192:8085 # VoiceCoach web client + - http://192.168.86.192:8181/health # Rongorongo (probe internal — Traefik basicAuth blocks external) - http://192.168.86.192:7880 # VoiceCoach LiveKit server relabel_configs: - source_labels: [__address__] @@ -153,7 +154,6 @@ scrape_configs: - https://vault.hobbs.farm # Vaultwarden - https://git.hobbs.farm # Gitea - https://coach.hobbs.farm # VoiceCoach web client - - https://rongorongo.hobbs.farm # Rongorongo decipherment engine relabel_configs: - source_labels: [__address__] target_label: __param_target diff --git a/stacks/traefik/dynamic/rongorongo.yml b/stacks/traefik/dynamic/rongorongo.yml index d7bf6af..6f5f7d3 100644 --- a/stacks/traefik/dynamic/rongorongo.yml +++ b/stacks/traefik/dynamic/rongorongo.yml @@ -5,6 +5,7 @@ http: entryPoints: - websecure middlewares: + - rongorongo-auth - security-headers service: rongorongo tls: @@ -21,6 +22,12 @@ http: redirectScheme: scheme: https permanent: true + rongorongo-auth: + basicAuth: + realm: "rongorongo.hobbs.farm" + # users entries are appended at deploy time as: : + users: + - "REPLACE_AT_DEPLOY" services: rongorongo: loadBalancer: