diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index d031afe..8aef98a 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -71,6 +71,7 @@ EXTERNAL_SERVICES = { "stirling_pdf": os.environ.get("STIRLING_PDF_URL", "https://pdf.jimmygan.com"), "vaultwarden": os.environ.get("VAULTWARDEN_URL", "https://vault.jimmygan.com"), "n8n": os.environ.get("N8N_URL", "https://n8n.jimmygan.com"), + "reasonix": os.environ.get("REASONIX_URL", "https://code.jimmygan.com"), "speedtest": os.environ.get("SPEEDTEST_URL", "https://speed.jimmygan.com"), "openconnector": os.environ.get("OPENCONNECTOR_URL", "https://connector.jimmygan.com"), } diff --git a/dashboard/backend/routers/__init__.py b/dashboard/backend/routers/__init__.py index 65c44f5..d6b0d05 100644 --- a/dashboard/backend/routers/__init__.py +++ b/dashboard/backend/routers/__init__.py @@ -17,6 +17,7 @@ ROUTER_CONFIGS = [ {"module": "opc_tasks", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]}, {"module": "opc_agents", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]}, {"module": "opc_projects", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]}, + {"module": "media_mgmt", "prefix": "/api/media", "deps": ["_inject_user", "require_page(\"media-ops\")"]}, ] WS_ROUTES = [ {"module": "terminal", "path": "/ws/terminal", "handler": "ws_endpoint"}, diff --git a/dashboard/backend/routers/media_mgmt.py b/dashboard/backend/routers/media_mgmt.py new file mode 100644 index 0000000..839ebb3 --- /dev/null +++ b/dashboard/backend/routers/media_mgmt.py @@ -0,0 +1,128 @@ +import json +import logging +import os +import asyncssh +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query +import config + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Absolute paths to media-mgmt scripts on NAS host +HEALTH_CHECK_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/health-check.sh" +STORAGE_REPORT_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/storage-report.sh" +SCAN_LIBRARY_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/scan-library.sh" +THUMBNAIL_CLINIC_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/thumbnail-clinic.sh" +SHUTTLE_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/ssd-shuttle.sh" +PHONE_INGEST_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/phone-to-nas.sh" + +LOG_DIR = "/volume1/homes/zjgump/media-mgmt/logs" + +async def run_host_command(cmd: str) -> tuple[int, str, str]: + """Execute a command on the NAS host via SSH.""" + try: + # Prepend PATH and NAS_HOST variables for host execution + full_cmd = f'PATH="/volume1/homes/zjgump/bin:$PATH" NAS_HOST="naslan" {cmd}' + async with asyncssh.connect( + config.SSH_HOST, + username=config.SSH_USER, + client_keys=[config.SSH_KEY_PATH], + known_hosts=None + ) as conn: + result = await conn.run(full_cmd) + return result.exit_status, result.stdout, result.stderr + except Exception as e: + logger.error(f"SSH command failed ({cmd}): {e}") + return 999, "", str(e) + +@router.get("/health") +async def get_health(): + """Runs health-check.sh and returns the exit status and stdout/stderr.""" + code, stdout, stderr = await run_host_command(f"bash {HEALTH_CHECK_SCRIPT}") + return { + "status": "ok" if code == 0 else "warning" if code == 1 else "error", + "exit_code": code, + "stdout": stdout, + "stderr": stderr + } + +@router.get("/storage") +async def get_storage(): + """Runs storage-report.sh -j and returns the parsed JSON storage report.""" + code, stdout, stderr = await run_host_command(f"bash {STORAGE_REPORT_SCRIPT} -j") + if code != 0: + raise HTTPException(status_code=500, detail=f"Storage report failed: {stderr}") + try: + return json.loads(stdout) + except json.JSONDecodeError: + return { + "error": "Failed to parse JSON from storage report", + "raw_output": stdout, + "stderr": stderr + } + +@router.post("/maintenance/scan") +async def run_scan(background_tasks: BackgroundTasks, library: str = Query(None)): + """Triggers scan-library.sh in the background and writes stdout/stderr to scan.log.""" + cmd = f"bash {SCAN_LIBRARY_SCRIPT}" + if library: + cmd += f" {library}" + + log_file = f"{LOG_DIR}/scan.log" + run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Scan Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &" + + code, stdout, stderr = await run_host_command(run_cmd) + if code != 0: + raise HTTPException(status_code=500, detail=f"Failed to start scan: {stderr}") + + return {"message": "Scan started in background", "log_file": log_file} + +@router.post("/maintenance/requeue") +async def run_requeue(background_tasks: BackgroundTasks): + """Triggers thumbnail-clinic.sh -r in the background and writes stdout/stderr to requeue.log.""" + cmd = f"bash {THUMBNAIL_CLINIC_SCRIPT} -r" + log_file = f"{LOG_DIR}/requeue.log" + + run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Requeue Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &" + + code, stdout, stderr = await run_host_command(run_cmd) + if code != 0: + raise HTTPException(status_code=500, detail=f"Failed to start thumbnail requeue: {stderr}") + + return {"message": "Thumbnail requeue started in background", "log_file": log_file} + +@router.get("/logs/{task_name}") +async def get_logs(task_name: str): + """Reads logs for a task from /volume1/homes/zjgump/media-mgmt/logs/{task_name}.log.""" + if task_name not in ["scan", "requeue", "shuttle", "phone-ingest"]: + raise HTTPException(status_code=400, detail="Invalid task name") + + log_path = f"/volume1/homes/zjgump/media-mgmt/logs/{task_name}.log" + if not os.path.exists(log_path): + return {"content": "No log file found."} + + try: + with open(log_path, "r") as f: + content = f.readlines() + return {"content": "".join(content[-200:])} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read log file: {e}") + +@router.get("/status") +async def get_active_status(): + """Checks if any ingestion/maintenance processes are currently running on the host.""" + processes = { + "scan": SCAN_LIBRARY_SCRIPT, + "requeue": THUMBNAIL_CLINIC_SCRIPT, + "shuttle": SHUTTLE_SCRIPT, + "phone-ingest": PHONE_INGEST_SCRIPT + } + + status = {} + for name, script_path in processes.items(): + code, stdout, stderr = await run_host_command(f"pgrep -f {script_path}") + status[name] = { + "running": code == 0, + "pid": stdout.strip() if code == 0 else None + } + return status diff --git a/dashboard/backend/tests/integration/test_media_mgmt.py b/dashboard/backend/tests/integration/test_media_mgmt.py new file mode 100644 index 0000000..4f2c7bc --- /dev/null +++ b/dashboard/backend/tests/integration/test_media_mgmt.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import patch, AsyncMock + +@pytest.mark.asyncio +async def test_get_health(test_app, admin_headers): + with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (0, "All checks passed\n", "") + response = test_app.get("/api/media/health", headers=admin_headers) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["exit_code"] == 0 + assert "All checks passed" in data["stdout"] + +@pytest.mark.asyncio +async def test_get_storage(test_app, admin_headers): + with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (0, '{"timestamp": "2026-07-11T05:37:57Z", "volume": {"path": "/volume1", "total": "14T", "used": "12T", "available": "2.2T", "used_pct": 85}}', "") + response = test_app.get("/api/media/storage", headers=admin_headers) + assert response.status_code == 200 + data = response.json() + assert "volume" in data + assert data["volume"]["total"] == "14T" + +@pytest.mark.asyncio +async def test_run_scan(test_app, admin_headers): + with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (0, "", "") + response = test_app.post("/api/media/maintenance/scan?library=DJI-Action6", headers=admin_headers) + assert response.status_code == 200 + data = response.json() + assert "Scan started" in data["message"] + mock_run.assert_called_once() + assert "scan-library.sh DJI-Action6" in mock_run.call_args[0][0] + +@pytest.mark.asyncio +async def test_get_active_status(test_app, admin_headers): + with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: + def side_effect(cmd): + if "scan-library.sh" in cmd: + return (0, "1234\n", "") + return (1, "", "") + mock_run.side_effect = side_effect + response = test_app.get("/api/media/status", headers=admin_headers) + assert response.status_code == 200 + data = response.json() + assert data["scan"]["running"] is True + assert data["scan"]["pid"] == "1234" + assert data["requeue"]["running"] is False diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index dceb082..2dd3205 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -13,6 +13,7 @@ import OPC from "./routes/tools/OPC.svelte"; import Transmission from "./routes/media/Transmission.svelte"; import Login from "./routes/Login.svelte"; + import MediaOps from "./routes/media/MediaOps.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; @@ -28,6 +29,7 @@ "litellm", "opc", "transmission", + "media-ops", ]); const navItems = [ @@ -40,12 +42,12 @@ { id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" }, // Media { id: "transmission", label: "Transmission", section: "media", description: "BitTorrent client" }, + { id: "media-ops", label: "Media Ops", section: "media", description: "Media library health, storage, and maintenance control panel" }, { label: "Navidrome", section: "media", remoteHref: "https://music.jimmygan.com", description: "Music streaming" }, { label: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" }, { label: "Audiobookshelf", section: "media", remoteHref: "https://books.jimmygan.com", description: "Audiobooks & podcasts" }, { label: "Immich", section: "media", remoteHref: "https://photos.jimmygan.com", description: "Photo and video backup" }, { label: "t-youtube", section: "media", remoteHref: "https://yt.jimmygan.com", description: "YouTube subscription reader" }, - { label: "Media Management", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media library management" }, // Tools { id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" }, { id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" }, @@ -236,6 +238,8 @@ {:else if page === "transmission" && hasPageAccess("transmission")} + {:else if page === "media-ops" && hasPageAccess("media-ops")} + {:else if page !== "terminal"} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index 4891bb5..3615a1b 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -49,7 +49,7 @@ { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" }, { label: "t-youtube", remoteHref: "https://yt.jimmygan.com", icon: "youtube" }, - { label: "Media Management", remoteHref: "https://media.jimmygan.com", icon: "wrench" }, + { id: "media-ops", label: "Media Ops", icon: "wrench" }, { id: "transmission", label: "Transmission", icon: "download" }, ]; @@ -62,6 +62,7 @@ { label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com", icon: "pdf", external: true }, { label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com", icon: "lock", external: true }, { label: "n8n", remoteHref: "https://n8n.jimmygan.com", icon: "n8n", external: true }, + { label: "Reasonix", remoteHref: "https://code.jimmygan.com", icon: "code", external: true }, { label: "Speedtest", remoteHref: "https://speed.jimmygan.com", icon: "speedtest", external: true }, { label: "OpenConnector", remoteHref: "https://connector.jimmygan.com", port: 3002, icon: "link", external: true }, ]; diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 1d905e7..b942561 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -217,3 +217,28 @@ export async function download(path, filename) { throw e; } } + +export function getMediaHealth() { + return get("/media/health"); +} + +export function getMediaStorage() { + return get("/media/storage"); +} + +export function startMediaScan(library = null) { + const path = library ? `/media/maintenance/scan?library=${encodeURIComponent(library)}` : "/media/maintenance/scan"; + return post(path, {}); +} + +export function startMediaRequeue() { + return post("/media/maintenance/requeue", {}); +} + +export function getMediaLogs(taskName) { + return get(`/media/logs/${taskName}`); +} + +export function getMediaStatus() { + return get("/media/status"); +} diff --git a/dashboard/frontend/src/routes/media/MediaOps.svelte b/dashboard/frontend/src/routes/media/MediaOps.svelte new file mode 100644 index 0000000..5724b33 --- /dev/null +++ b/dashboard/frontend/src/routes/media/MediaOps.svelte @@ -0,0 +1,483 @@ + + +
+
+
+

Media Ops

+

Synology Immich ingestion pipelines, health, storage reports, and maintenance.

+
+
+ + +
+
+ +
+ +
+ + +
+

Ingestion & Maintenance Tasks

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Task NameStatusActions
+
Library Scan
+
Trigger Immich scanning API for ingest paths.
+
+ + {#if status.scan?.running} + + {/if} + {getStatusLabel('scan')} + + +
+ + + +
+
+
Thumbnail Clinic
+
Detect missing thumbnails and requeue Bull jobs.
+
+ + {#if status.requeue?.running} + + {/if} + {getStatusLabel('requeue')} + + +
+ + +
+
+
SSD Shuttle
+
Ingest/move files from portable SSD shuttle to NAS.
+
+ + {#if status.shuttle?.running} + + {/if} + {getStatusLabel('shuttle')} + + + +
+
Phone Sync
+
Upload/sync phone media folders to target storage.
+
+ + {#if status['phone-ingest']?.running} + + {/if} + {getStatusLabel('phone-ingest')} + + + +
+
+
+ + +
+
+
+

Task Output Console

+

Showing last 200 lines for: {selectedTask}

+
+
+ + +
+
+ +
{logs}
+
+ +
+ + +
+ + +
+

Live Diagnostics Status

+ + {#if health} +
+ + {#if health.status === 'ok'} + + + +
+
All Checks Green
+
Immich API and Docker containers running properly.
+
+ {:else if health.status === 'warning'} + + + +
+
Passed with Warnings
+
Checks passed but disk warning threshold was met.
+
+ {:else} + + + +
+
Services Degraded
+
One or more components are offline or failing pings.
+
+ {/if} +
+ +
+ + Diagnostics Details + + +
{health.stdout || health.stderr}
+
+ {:else} +
+ {diagLoading ? 'Running check...' : 'No diagnostics run yet.'} +
+ {/if} +
+ + +
+

Storage Breakdown

+ + {#if storage} +
+ +
+
+ Volume Usage ({storage.volume.path}) + {storage.volume.used} / {storage.volume.total} +
+
+
+
+
+ {storage.volume.available} free + {storage.volume.used_pct}% used +
+
+ + +
+

Libraries

+ + {#each storage.libraries as lib} +
+
+ {lib.name} + {lib.total_size} +
+ +
+ Files: {lib.total_files} + ({lib.images} imgs, {lib.videos} vids) +
+ +
+
+
+
+ {lib.volume_pct}% of total storage +
+
+ {/each} +
+ + +
+

Ingest Folders

+
+ video/DJI_001 + {storage.directories.dji_001.size} +
+
+ immich/upload + {storage.directories.immich_upload.size} +
+
+ +
+ {:else} +
+ Loading storage details... +
+ {/if} +
+ +
+
+
diff --git a/reasonix/.dockerignore b/reasonix/.dockerignore new file mode 100644 index 0000000..a3ff191 --- /dev/null +++ b/reasonix/.dockerignore @@ -0,0 +1,6 @@ +data/ +.env +.env.example +.git +.gitignore +*.md diff --git a/reasonix/.env.example b/reasonix/.env.example new file mode 100644 index 0000000..2f379c6 --- /dev/null +++ b/reasonix/.env.example @@ -0,0 +1,2 @@ +# DeepSeek API key for litellm proxy +DEEPSEEK_LOCAL_API_KEY=sk-your-key-here diff --git a/reasonix/Dockerfile b/reasonix/Dockerfile new file mode 100644 index 0000000..4f17247 --- /dev/null +++ b/reasonix/Dockerfile @@ -0,0 +1,17 @@ +FROM node:alpine AS build + +RUN npm install -g reasonix@1.17.10 + +FROM node:alpine + +COPY --from=build /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=build /usr/local/bin/reasonix /usr/local/bin/reasonix + +RUN addgroup -S reasonix && adduser -S reasonix -G reasonix + +USER reasonix +WORKDIR /home/reasonix + +EXPOSE 8787 + +ENTRYPOINT ["reasonix", "serve", "--addr", "0.0.0.0:8787", "--auth", "none"] diff --git a/reasonix/docker-compose.yml b/reasonix/docker-compose.yml new file mode 100644 index 0000000..073ca03 --- /dev/null +++ b/reasonix/docker-compose.yml @@ -0,0 +1,34 @@ +services: + reasonix: + build: + context: . + dockerfile: Dockerfile + image: reasonix:latest + container_name: reasonix + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=false" + ports: + - "127.0.0.1:8787:8787" + environment: + - DEEPSEEK_LOCAL_API_KEY=${DEEPSEEK_LOCAL_API_KEY} + - TZ=Asia/Shanghai + volumes: + - ./reasonix.toml:/home/reasonix/.reasonix/config.toml:ro + - ./data:/home/reasonix/.reasonix + networks: + - nas-dashboard_internal + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8787"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + nas-dashboard_internal: + external: true diff --git a/reasonix/reasonix.toml b/reasonix/reasonix.toml new file mode 100644 index 0000000..61c2920 --- /dev/null +++ b/reasonix/reasonix.toml @@ -0,0 +1,18 @@ +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +context_window = 1000000 + +[[providers]] +name = "deepseek-pro" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-pro" +api_key_env = "DEEPSEEK_API_KEY" +context_window = 1000000 + +[defaults] +model = "deepseek-flash" diff --git a/scripts/cloudflare-dns.sh b/scripts/cloudflare-dns.sh new file mode 100755 index 0000000..1c3be38 --- /dev/null +++ b/scripts/cloudflare-dns.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Cloudflare DNS upsert — create or update a DNS record. +# Usage: CLOUDFLARE_API_TOKEN=xxx ./scripts/cloudflare-dns.sh [ttl] +# +# Examples: +# cloudflare-dns.sh jimmygan.com A code 161.33.182.109 +# cloudflare-dns.sh jimmygan.com CNAME www jimmygan.com 120 +set -euo pipefail + +ZONE="${1:?Usage: $0 [ttl]}" +TYPE="${2:?}" +NAME="${3:?}" +CONTENT="${4:?}" +TTL="${5:-120}" + +if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then + echo "❌ CLOUDFLARE_API_TOKEN not set" >&2 + exit 1 +fi + +API="https://api.cloudflare.com/client/v4" +AUTH_HEADER="Authorization: Bearer $CLOUDFLARE_API_TOKEN" +CONTENT_TYPE="Content-Type: application/json" + +echo "🔍 Looking up zone: $ZONE" +ZONE_RESP=$(curl -s -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + "$API/zones?name=$ZONE&status=active") +ZONE_ID=$(echo "$ZONE_RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +if not d.get('success'): + print('API error:', d.get('errors'), file=sys.stderr) + sys.exit(1) +result = d.get('result', []) +if not result: + print('Zone not found:', '$ZONE', file=sys.stderr) + sys.exit(1) +print(result[0]['id']) +") + +echo "✅ Zone ID: $ZONE_ID" + +echo "🔍 Checking existing record: $NAME.$ZONE ($TYPE)" +RECORD_RESP=$(curl -s -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + "$API/zones/$ZONE_ID/dns_records?type=$TYPE&name=$NAME.$ZONE") +RECORD_ID=$(echo "$RECORD_RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +if not d.get('success'): + sys.exit(0) # skip, will create +result = d.get('result', []) +if result: + # Prefer exact content match + for r in result: + if r['content'] == '$CONTENT': + print(r['id']) + sys.exit(0) + # Fall back to first match + print(result[0]['id']) +") + +FULL_NAME="$NAME.$ZONE" + +if [ -n "$RECORD_ID" ]; then + echo "🔄 Updating existing record ($RECORD_ID): $FULL_NAME → $CONTENT" + RESP=$(curl -s -X PUT \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d "{\"type\":\"$TYPE\",\"name\":\"$FULL_NAME\",\"content\":\"$CONTENT\",\"ttl\":$TTL,\"proxied\":false}" \ + "$API/zones/$ZONE_ID/dns_records/$RECORD_ID") + SUCCESS=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')") + if [ "$SUCCESS" = "true" ]; then + echo "✅ Updated: $FULL_NAME → $CONTENT" + else + echo "❌ Failed to update:" >&2 + echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errors'))" >&2 + exit 1 + fi +else + echo "🆕 Creating new record: $FULL_NAME → $CONTENT" + RESP=$(curl -s -X POST \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d "{\"type\":\"$TYPE\",\"name\":\"$FULL_NAME\",\"content\":\"$CONTENT\",\"ttl\":$TTL,\"proxied\":false}" \ + "$API/zones/$ZONE_ID/dns_records") + SUCCESS=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')") + if [ "$SUCCESS" = "true" ]; then + echo "✅ Created: $FULL_NAME → $CONTENT" + else + echo "❌ Failed to create:" >&2 + echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errors'))" >&2 + exit 1 + fi +fi diff --git a/specs/sprint-06-code-quality.md b/specs/sprint-06-code-quality.md index 4cbbd00..f6ac8b3 100644 --- a/specs/sprint-06-code-quality.md +++ b/specs/sprint-06-code-quality.md @@ -106,6 +106,6 @@ - [x] CI workflows are DRY (or documented why they can't be) (or documented why they can't be) - [x] Login.svelte is SSR-safe - [x] No legacy localStorage refresh token logic (or clearly documented) (or clearly documented) -- [ ] All backend tests pass -- [ ] All frontend tests pass +- [x] All backend tests pass +- [x] All frontend tests pass - [x] `npm run build` succeeds diff --git a/tmp_remote/Caddyfile_nas b/tmp_remote/Caddyfile_nas index daa1457..52b5a7c 100755 --- a/tmp_remote/Caddyfile_nas +++ b/tmp_remote/Caddyfile_nas @@ -141,6 +141,17 @@ vault.jimmygan.com { reverse_proxy 127.0.0.1:8222 } +code.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth 127.0.0.1:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy 127.0.0.1:8787 +} + speed.jimmygan.com { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} diff --git a/tmp_remote/Caddyfile_vps b/tmp_remote/Caddyfile_vps index 6fbb557..1240e70 100644 --- a/tmp_remote/Caddyfile_vps +++ b/tmp_remote/Caddyfile_vps @@ -66,3 +66,9 @@ photos-app.jimmygan.com { } } } + +code.jimmygan.com { + import security_headers + import authelia + reverse_proxy 100.78.131.124:8787 +}