infra: rongorongo state-sync backend + Traefik basicAuth
Add a Flask backend service for cross-device state persistence, storing JSON state on an NFS volume at /mnt/docker-data/configs/rongorongo/data. Frontend Dockerfile/nginx.conf reorganised into frontend/ subdirectory; frontend API calls now route to /api/* handled by the backend container over a dedicated bridge network. Traefik basicAuth middleware added to the public URL with the bcrypt hash deliberately kept out of git (REPLACE_AT_DEPLOY placeholder in rongorongo.yml). Prometheus probe moved from https-external to internal http://192.168.86.192:8181/health to bypass the new auth wall. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094)
|
||||||
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
|
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
|
||||||
├── [COMPOSE] Farm Agent (:8889)
|
├── [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
|
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
|
||||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
|
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
|
||||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
├── [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)
|
- 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 Manager: :8888 (dashboard + API)
|
||||||
- Farm Agent: :8889
|
- 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
|
- ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04
|
||||||
|
|
||||||
**hf-pdocker-02 (192.168.86.100):**
|
**hf-pdocker-02 (192.168.86.100):**
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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/<image_hash>")
|
||||||
|
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/<image_hash>")
|
||||||
|
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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask==3.0.3
|
||||||
|
gunicorn==23.0.0
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
rongorongo:
|
rongorongo-api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: rongorongo:latest
|
image: rongorongo-api:latest
|
||||||
container_name: rongorongo
|
container_name: rongorongo-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
networks:
|
||||||
- "8181:80"
|
- rongorongo
|
||||||
mem_limit: 128M
|
volumes:
|
||||||
memswap_limit: 128M
|
- /mnt/docker-data/configs/rongorongo/data:/data
|
||||||
|
mem_limit: 256M
|
||||||
|
memswap_limit: 256M
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -23,3 +25,36 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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
|
||||||
|
|||||||
+3
-2
@@ -2,11 +2,12 @@ FROM nginx:1.25-alpine
|
|||||||
|
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
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
|
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 \
|
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
|
EXPOSE 80
|
||||||
|
|
||||||
@@ -727,6 +727,7 @@ select option { background: var(--surface); }
|
|||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<label class="api-key-label">Anthropic API Key</label>
|
<label class="api-key-label">Anthropic API Key</label>
|
||||||
<input class="api-key-input" type="password" id="apiKeyInput" placeholder="sk-ant-…" oninput="saveApiKey(this.value)">
|
<input class="api-key-input" type="password" id="apiKeyInput" placeholder="sk-ant-…" oninput="saveApiKey(this.value)">
|
||||||
|
<div id="syncStatus" style="margin-top:0.5rem;font-size:0.75rem;color:#888;">● synced</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -1167,32 +1168,107 @@ const S = {
|
|||||||
confidenceHistory: {}, // anchorId -> [{ts, score}]
|
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() {
|
function save() {
|
||||||
|
if (_saveSuppressed || !_loaded) return;
|
||||||
|
clearTimeout(_saveTimer);
|
||||||
|
_saveTimer = setTimeout(_flushSave, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _flushSave() {
|
||||||
try {
|
try {
|
||||||
const snap = {
|
const snap = {
|
||||||
|
images: S.images.map(i => ({ name: i.name, mimeType: i.mimeType, hash: i.hash })),
|
||||||
glyphs: S.glyphs,
|
glyphs: S.glyphs,
|
||||||
anchors: S.anchors,
|
anchors: S.anchors,
|
||||||
analyses: S.analyses.slice(0,10),
|
analyses: S.analyses.slice(0,10),
|
||||||
feasibilityRuns: S.feasibilityRuns.slice(0,5),
|
feasibilityRuns: S.feasibilityRuns.slice(0,5),
|
||||||
confidenceHistory: S.confidenceHistory,
|
confidenceHistory: S.confidenceHistory,
|
||||||
};
|
};
|
||||||
localStorage.setItem('rongo_state', JSON.stringify(snap));
|
const r = await fetch('/api/state', {
|
||||||
} catch(e) {}
|
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 {
|
try {
|
||||||
const raw = localStorage.getItem('rongo_state');
|
const r = await fetch('/api/state');
|
||||||
if (!raw) return;
|
if (!r.ok) throw new Error('GET /api/state HTTP ' + r.status);
|
||||||
const snap = JSON.parse(raw);
|
const snap = await r.json();
|
||||||
S.glyphs = snap.glyphs || [];
|
S.glyphs = snap.glyphs || [];
|
||||||
S.anchors = snap.anchors || [];
|
S.anchors = snap.anchors || [];
|
||||||
S.analyses = snap.analyses || [];
|
S.analyses = snap.analyses || [];
|
||||||
S.feasibilityRuns = snap.feasibilityRuns || [];
|
S.feasibilityRuns = snap.feasibilityRuns || [];
|
||||||
S.confidenceHistory = snap.confidenceHistory || {};
|
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();
|
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) {
|
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/'))); });
|
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]));
|
fileInput.addEventListener('change', e => handleFiles([...e.target.files]));
|
||||||
|
|
||||||
function handleFiles(files) {
|
async function handleFiles(files) {
|
||||||
files.forEach(f => {
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await new Promise((res, rej) => {
|
||||||
const fr = new FileReader();
|
const fr = new FileReader();
|
||||||
fr.onload = e => {
|
fr.onload = e => res(e.target.result);
|
||||||
S.images.push({ name: f.name, dataUrl: e.target.result, base64: e.target.result.split(',')[1], mimeType: f.type||'image/jpeg' });
|
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();
|
renderImageStrip();
|
||||||
refreshAll();
|
refreshAll();
|
||||||
toast(`Loaded ${f.name}`);
|
toast(`Loaded ${f.name}`);
|
||||||
};
|
} catch (e) {
|
||||||
fr.readAsDataURL(f);
|
console.error('upload failed', f.name, e);
|
||||||
});
|
toast(`Upload failed: ${f.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImageStrip() {
|
function renderImageStrip() {
|
||||||
@@ -1312,10 +1406,17 @@ function renderImageStrip() {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeImage(i) {
|
async function removeImage(i) {
|
||||||
|
const img = S.images[i];
|
||||||
|
if (!img) return;
|
||||||
S.images.splice(i,1);
|
S.images.splice(i,1);
|
||||||
renderImageStrip();
|
renderImageStrip();
|
||||||
refreshAll();
|
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() {
|
function refreshAll() {
|
||||||
@@ -2032,12 +2133,17 @@ function downloadReport() {
|
|||||||
toast('Session exported');
|
toast('Session exported');
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
async function clearSession() {
|
||||||
if (!confirm('Clear all session data? This cannot be undone.')) return;
|
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={};
|
S.images=[]; S.glyphs=[]; S.anchors=[]; S.analyses=[]; S.feasibilityRuns=[]; S.confidenceHistory={};
|
||||||
try { localStorage.removeItem('rongo_state'); } catch(e){}
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
renderImageStrip();
|
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');
|
toast('Session cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -5,6 +5,8 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 30m;
|
||||||
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" 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;
|
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 on;
|
||||||
gzip_types text/html text/css application/javascript;
|
gzip_types text/html text/css application/javascript application/json;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
@@ -23,6 +25,19 @@ server {
|
|||||||
add_header Content-Type text/plain;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
expires -1;
|
expires -1;
|
||||||
@@ -70,6 +70,7 @@ scrape_configs:
|
|||||||
- http://192.168.86.192:9093 # Alertmanager
|
- http://192.168.86.192:9093 # Alertmanager
|
||||||
- http://192.168.86.192:18795 # Spirit (infra ops agent)
|
- http://192.168.86.192:18795 # Spirit (infra ops agent)
|
||||||
- http://192.168.86.192:8085 # VoiceCoach web client
|
- 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
|
- http://192.168.86.192:7880 # VoiceCoach LiveKit server
|
||||||
relabel_configs:
|
relabel_configs:
|
||||||
- source_labels: [__address__]
|
- source_labels: [__address__]
|
||||||
@@ -153,7 +154,6 @@ scrape_configs:
|
|||||||
- https://vault.hobbs.farm # Vaultwarden
|
- https://vault.hobbs.farm # Vaultwarden
|
||||||
- https://git.hobbs.farm # Gitea
|
- https://git.hobbs.farm # Gitea
|
||||||
- https://coach.hobbs.farm # VoiceCoach web client
|
- https://coach.hobbs.farm # VoiceCoach web client
|
||||||
- https://rongorongo.hobbs.farm # Rongorongo decipherment engine
|
|
||||||
relabel_configs:
|
relabel_configs:
|
||||||
- source_labels: [__address__]
|
- source_labels: [__address__]
|
||||||
target_label: __param_target
|
target_label: __param_target
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ http:
|
|||||||
entryPoints:
|
entryPoints:
|
||||||
- websecure
|
- websecure
|
||||||
middlewares:
|
middlewares:
|
||||||
|
- rongorongo-auth
|
||||||
- security-headers
|
- security-headers
|
||||||
service: rongorongo
|
service: rongorongo
|
||||||
tls:
|
tls:
|
||||||
@@ -21,6 +22,12 @@ http:
|
|||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
permanent: true
|
||||||
|
rongorongo-auth:
|
||||||
|
basicAuth:
|
||||||
|
realm: "rongorongo.hobbs.farm"
|
||||||
|
# users entries are appended at deploy time as: <user>:<bcrypt-hash>
|
||||||
|
users:
|
||||||
|
- "REPLACE_AT_DEPLOY"
|
||||||
services:
|
services:
|
||||||
rongorongo:
|
rongorongo:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
|||||||
Reference in New Issue
Block a user