commit 11c1a29b60bb8eced62c0bafc464104f5148c280 Author: seekr Date: Fri Aug 7 09:36:35 2026 +0000 initial commit: rclone sync stack with monitoring UI diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9a18a34 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "PowerShell(node --version 2>&1)", + "PowerShell(npm --version 2>&1)" + ] + } +} diff --git a/.env b/.env new file mode 100644 index 0000000..de312b3 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +# Local simulation credentials (fake Synology container) +SYNOLOGY_PASS=LocalTest123 +RC_PASSWORD=rc-admin-123 + +# Agent <-> Collector shared secret +AGENT_TOKEN=bd8117d2528845f04ad6311111758dedf4ce1dbe65934bbe + +# Real office NAS — used by the collector for server-side verification +REAL_SYNOLOGY_HOST=takeleap.in +REAL_SYNOLOGY_PORT=26 +REAL_SYNOLOGY_USER=sr-upload +REAL_SYNOLOGY_PASS=T@keleap@123 + +ORGANISATION=ORG +SITENAME=GCBOT diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3823835 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# ============================================================================= +# .env.example — Safe template. Copy to .env and fill in real values. +# DO NOT put real passwords here. This file IS committed to git. +# ============================================================================= + +# ── Fake/local Synology (used by the local Docker simulation) ───────────────── +SYNOLOGY_PASS=change-me-local +RC_PASSWORD=change-me-rc + +# ── Agent ↔ Collector shared secret ────────────────────────────────────────── +# Generate with: openssl rand -hex 24 +AGENT_TOKEN=change-me-generate-with-openssl + +# ── Real NAS credentials (used by the collector for server-side verification) ─ +# These should be READ-ONLY credentials if possible. Rotate from production. +REAL_SYNOLOGY_HOST=your-nas-hostname.example.com +REAL_SYNOLOGY_PORT=22 +REAL_SYNOLOGY_USER=your-sftp-user +REAL_SYNOLOGY_PASS=your-sftp-password + +# ── Site identity ───────────────────────────────────────────────────────────── +ORGANISATION=ORG +SITENAME=GCBOT diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49af90d --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# ============================================================================= +# .gitignore — R-clone / rclone monitoring project +# ============================================================================= + +# ── Environment & secrets ──────────────────────────────────────────────────── +# .env IS committed here (private repo, creds needed on VM after git pull). +# If this repo ever becomes shared/public, move secrets to a vault instead. + +# ── Runtime data (large binary files + generated logs) ─────────────────────── +# These are created at runtime; committing them bloats the repo. +server-data/ +client/Sync_logs/ +client/image_root_dir/ +client/csv_files/ +client2/ + +# Keep the empty directory stubs so Docker volume mounts work out of the box +!client/image_root_dir/.gitkeep +!client/csv_files/.gitkeep +!client/Sync_logs/.gitkeep + +# ── Large binary / generated files ─────────────────────────────────────────── +# Video files — too large for git; use LFS or store externally +*.mp4 +*.avi +*.mkv +*.mov + +# ML model weights — large binaries; store in a model registry or LFS +*.pt +*.pth +*.onnx +*.weights +*.bin + +# Zip archives — generated by scripts, not source +*.zip + +# ── rclone config (generated at container start from env vars) ─────────────── +*.conf +rclone.conf + +# ── OS & editor noise ──────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini +*.swp +*.swo +.idea/ +.vscode/settings.json + +# ── Node / frontend ────────────────────────────────────────────────────────── +node_modules/ +frontend/node_modules/ +dist/ +build/ +.next/ + +# ── Python ─────────────────────────────────────────────────────────────────── +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# ── Docker ─────────────────────────────────────────────────────────────────── +# Keep docker-compose files but ignore override files with local tweaks +docker-compose.override.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..69805c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,193 @@ +# R-clone — Rclone→Synology Sync Monitoring Project + +## Mission +The office deploys client machines (Linux, GPU) that process road-survey videos/images +and sync results to a Synology NAS via rclone. Nothing tracks whether syncs succeed. +**Goal: build a monitoring UI + alert system** for the fleet. +My stack: MERN (Node/Express/React/Mongo), FastAPI, intermediate Python, basic Linux. + +## Production architecture (from office files — do NOT commit real creds) +Each client site machine runs (via a generated docker-compose): +- `algorithm3` — GPU/YOLO container, processes video → anomaly images + result CSVs +- `mysql`, `socket-server`, `local-gui-backend` — local site stack +- `watchtower` — auto-pulls new images every 300s from the private registry +- `rclone-synology-sync` — Alpine rclone image + `sync.sh` loop (this repo has exact copies) + +### The sync loop (`sync-container/sync.sh` — byte-identical to production) +- Writes rclone.conf for an **SFTP remote** (`synodrive`) → Synology at a public + hostname, non-standard port, single shared user (creds in office compose only). +- Starts `rclone rcd --rc-web-gui` on **:5572** (RC API + built-in web GUI). +- Infinite loop: for each `SYNC_n=direction:local:remote` env var, run + `rclone copy --size-only` (up = local→NAS, down = NAS→local), then sleep + `SYNC_INTERVAL` seconds (**seconds — the "# minutes" comment in prod compose is wrong; + effective prod interval is 120s**). +- Logs to `/logs/`: `sync.log` (everything), `stats.log` (pretty per-round), + `errors.log` (grep " ERROR "), `completed.log` (grep "Copied|Moved"), `rc.log`. + +### Production sync pairs (per site; ORG/SITENAME templated) +| # | Dir | What | +|---|-----|------| +| 1 | up | anomaly images → `Saudi_Video_Sync/SeekRight/Anomaly///TEST` | +| 2 | down | master sheets ← `ClientSync/ALGORITHM_DEPLOYMENT/Master_Sheets//` | +| 3 | down | yolov8 weights ← `ClientSync/ALGORITHM_DEPLOYMENT/Models/yolov8` | +| 4 | down | NIGHT weights ← `ClientSync/ALGORITHM_DEPLOYMENT/Models/NIGHT` | +| 5 | up | result CSVs → `ClientSync/ALGORITHM_DEPLOYMENT/csv_files//` | + +**Monitoring focus = pairs 1 & 5** (client-produced files that must reach the NAS). + +### Known production issues (verified, mention when relevant) +1. `RC_PASSWORD` never set in prod compose → **unauthenticated RC API** on host + network :5572 (full read/write to NAS + local disk from the LAN). +2. Plaintext creds in compose + git token in `generate_compose.sh` → flagged for rotation. +3. `--size-only` + `copy` (not `sync`): same-size edits never re-transfer; deletes + never propagate. "Synced" is weaker than it sounds. +4. **The built-in Web GUI shows nothing useful** — see below. + +## KEY FINDING: rclone's built-in UI exists but can't see the syncs +`rclone rcd --rc-web-gui` serves a React GUI on :5572 (login admin/$RC_PASSWORD). +**Verified locally: while 5 pairs were actively copying, `POST /core/stats` returned +all zeros.** The `rclone copy` commands are separate OS processes; the rcd daemon only +reports jobs started through its own API. So the prod GUI is decorative. +Options for the real project: +- (A) Ship our own agent that tails logs + POSTs to a central collector (recommended; + zero change to sync behavior), or +- (B) Rewrite sync.sh to submit jobs via `rclone rc sync/copy _async=true` so rcd/GUI + sees them — bigger change, still no fleet view, history, or alerts. +- Adding `--use-json-log` to the rclone copy invocation would make log parsing trivial + (structured JSON per line) — one-flag change worth proposing. + +## This repo = full local replica (WSL2 Ubuntu + docker-ce, NOT Docker Desktop) +``` +docker-compose.yml 3 services (see header comment for deviations from prod) +├─ synology atmoz/sftp:alpine — fake NAS; ./server-data ↔ /home/sr-upload/upload +│ reachable from Windows: sftp -P 2222 sr-upload@localhost +├─ rclone-synology-sync built from sync-container/ (EXACT prod Dockerfile+sync.sh), +│ same 5 pairs, remote paths prefixed `upload/` (sftp chroot), +│ SYNC_INTERVAL=60, GUI/RC on http://localhost:5572 +└─ file-generator generator/generate.sh — fake algorithm3, drops a random-bytes + .jpg + a results .csv into the up-sync dirs every 45s +.env local-only fake creds (SYNOLOGY_PASS, RC_PASSWORD, ORG, SITENAME) +client/ the "client machine's disk": image_root_dir, csv_files (sources), + GCBOT, yolov8, NIGHT (down targets), Sync_logs (all 5 log files) +server-data/ the "NAS content" — inspect from Windows Explorer +``` +Verified working 2026-08-05: all 5 pairs green, files flowing both directions, +errors.log has real historical failures (good test data for alert logic). + +### Run / operate (always via WSL) +```bash +wsl -e bash -c "cd /mnt/c/Users/seekr/OneDrive/Desktop/work/R-clone && docker compose up -d --build" +docker compose logs -f file-generator # watch fake files being produced +tail -f client/Sync_logs/stats.log # watch rounds +docker compose down # stop +``` + +### WSL/Windows gotchas (cost us an hour — don't rediscover) +- **WSL VM idles out when no wsl.exe session is open** → dockerd dies → all + restart-policy containers restart. Keep a `wsl` terminal open (or a background + `wsl -e sleep infinity`) while testing. Symptom: every container "Up N seconds", + sync.log shows repeated "Container started". +- **/mnt/c bind mounts reject utimes()** → rclone up-syncs fail with + `SetModTime failed: permission denied`. Fixed via `RCLONE_SFTP_SET_MODTIME=false` + env var in compose (rclone reads RCLONE_* env as config → sync.sh stays untouched). +- Shell scripts must be LF, not CRLF (`sed -i 's/\r$//'` after editing on Windows). +- **Old production leftovers exist on this machine** (user ran the office compose + ~3 weeks ago): containers `algorithm` (dead, exit 137), `mysql`, `socket-server` + (crash-loops), `local-gui-backend`, `watchtower` (polls office registry!). + Parked with `docker update --restart=no` + stopped. Don't remove without asking; + revive with `docker start `. + +## Verified: RC API gives every UI metric (2026-08-05 experiment) +`monitor/watch_batch.py` submitted 30×5MB files via `POST /sync/copy _async=true` +(src `./client/batch_test`, throttled with `core/bwlimit`) and polled live: +- **scheduled** = `core/stats.totalTransfers` · **done** = `.transfers` +- **moving now** = `.transferring[]` (name, %, speed, per-file eta; max 4 shown = `--transfers`) +- **queued** = totalTransfers − transfers − len(transferring) +- **overall ETA/speed/bytes** = `.eta`, `.speed`, `.bytes/.totalBytes` +- **per-file duration** = `core/transferred[]` `completed_at − started_at` (+ error field) +All 30 arrived; per-file report printed. Gotchas: per-file `eta` can be `None` +early; totalTransfers grows during discovery (not instantly 30); always pass +`{"group": "job/"}` to scope stats to one job. +**Constraint**: RC only sees jobs submitted via RC. The prod loop's `rclone copy` +processes are invisible to it → for live per-file UI data in prod, sync.sh v2 +should submit pairs via `rclone rc sync/copy _async=true` + poll, instead of +spawning `rclone copy`. Log parsing alone gives only aggregate 5s stats lines ++ completion events. + +## Monitoring stack v1 — BUILT and running (2026-08-06) +`agent (per machine) → collector (FastAPI+Mongo) → React dashboard`, all in compose: +- `monitor/agent/agent.py` — stdlib-only sidecar; tails `/logs/sync.log` (ro mount), + regex-parses rounds/pairs/files/errors/progress, POSTs `/api/ingest` every 5s + (empty POST = heartbeat). Works against byte-identical prod sync.sh. +- `monitor/collector/main.py` — FastAPI + pymongo (**tz_aware=True — naive-vs-aware + datetime bug otherwise**). Collections: machines, pairs, events. Endpoints: + POST `/api/ingest`, GET `/api/overview` (fleet + computed alerts), GET + `/api/machines/{id}/events?type=&limit=`. Serves React build from `/app/static`. + Alert rules: offline (no heartbeat >30s, critical), pair last_status=fail + (serious), errors_1h>0 (warning). +- `frontend/` — React 18 + Vite. Fleet cards (status badge, stat tiles, pair rows, + live progress bar), alerts bar, click card → events panel with tabs + (Files/Errors/Rounds/Pair results). Polls every 4s. Dark theme, dataviz status + tokens (#0ca30c/#fab219/#ec835a/#d03b3b), icon+label never color alone. + Build: `cd frontend && npm run build` (Windows npm; dist/ is volume-mounted + into collector — rebuild frontend = just `npm run build`, no docker rebuild). +- Fleet demo: machine 2 = RIYADH-01 (`rclone-sync-riyadh`, 2 pairs, 90s interval, + own `client2/` dirs + generator + agent). +- UI: **http://localhost:8000** · verified: both machines online, 7 pairs ok, + files_1h counting. Alert-firing path not yet demo'd (user declined stopping a + local container — do not stop/restart containers without asking first). + +## v1.1 — remote machines + RMM surface (2026-08-06) +- **Agent auth**: `AGENT_TOKEN` shared secret (in `.env`); collector rejects + ingest without `Authorization: Bearer ` (verified 401). Empty token = + auth off (never for remote use). +- **RMM endpoints**: GET `/api/health` (liveness), GET `/api/alerts` + (lightweight polling), CORS enabled (`CORS_ORIGINS` env). Integration points + for the user's future RMM: poll alerts, or embed /api/overview data. +- **`deploy/remote-machine/`**: self-contained bundle (compose + .env.example) + for any external machine (e.g. a friend's system): rclone-sync (1 up pair from + the algorithm's OUTPUT_DIR) + agent → user's collector. Remote machine needs a + network path to the collector — recommend Tailscale; COLLECTOR_URL then is the + tailnet IP. Bundle builds from ../../sync-container and ../../monitor/agent. +- Transfer visibility today: completed files ✓ (file_synced events), aggregate + live progress per pair ✓ (5s stats → progress bar), per-file in-flight + + pending queue ✗ — needs RC-submitted jobs (sync.sh v2, see RC section). + +## v1.2 — server-side verification / audit feature (2026-08-06) — WORKING +The audit-team answer: "did the file actually reach the Synology?" proven from +the collector, no one logs into anything. +- Agent `WATCH_DIRS` env: mounts up-pair source dirs ro at the same container + paths as sync (`/sources/...`), ships file inventory (name/size/mtime) with + every ingest. +- Collector: own rclone remote (start.sh writes config; rclone installed via + apt in Dockerfile), background thread `rclone lsjson --recursive` on every + up-pair remote path every VERIFY_INTERVAL (45s local; use 300s+ in prod). + `pending` = local file not in server listing; `missing` = pending with mtime + older than MISSING_GRACE_S (600) → serious alert with file names. +- UI: 4th tile "pending → server" + amber `N pending` / red `N missing!` chips + per pair. Verified live: pending counts rise as generator drops files, fall + to 0 after each sync round. +- Friend's machine (compose seen 2026-08-06): logs at + `/home/testing/JAGAN/Prerequisites/Sync_logs`, SITENAME=TEST, standard 5 + pairs. deploy/remote-machine bundle fits as-is; needs Tailscale (or LAN) to + reach the collector + AGENT_TOKEN. Nothing needed from his algorithm code — + agent reads only Sync_logs + source dirs. + +## Roadmap for the actual deliverable +1. **Agent** (per client machine, sidecar container): tail `/logs/*.log` (or JSON log), + parse rounds/pairs/files/errors, POST events + 60s heartbeat to collector. +2. **Collector**: FastAPI + MongoDB. Models: Machine, SyncPair, Round, FileEvent, Alert. +3. **Server-side verification** (the feature logs can't provide): collector has its own + rclone SFTP remote to the NAS; `rclone lsjson` per site path to confirm reported + uploads actually arrived; alert if a file is still missing after a grace period. + Missed-heartbeat = dead machine alert (most important signal). +4. **UI**: React dashboard — fleet grid (last round, last heartbeat, error counts), + per-machine pair detail, file history, live progress later via RC API. +5. **Alerts**: dashboard + email first; Slack/Teams webhook later. Rules before channels. + +## Conventions for this repo +- Never write real office credentials/hostnames into files here; use `.env` pattern + and placeholders like `/`. +- `sync-container/sync.sh` stays byte-identical to production — replica fidelity is + the point. Fixes go in compose env vars or new sidecar services, and proposed + prod changes get documented in this file instead. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..16886d7 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,122 @@ +# Fleet Monitoring — End-to-End Setup + +Connect any machine running the production `rclone-synology-sync` container to +the monitoring dashboard. Two sides: **collector** (your machine — runs the UI) +and **agent** (each monitored machine). + +``` +his machine your machine +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ existing prod stack │ │ collector (FastAPI) :8000 │ +│ └─ writes Sync_logs/ │ Tailscale │ ├─ MongoDB │ +│ NEW: rclone-agent ───────┼──────────────▶│ ├─ React dashboard │ +│ (reads logs + folders, │ HTTP+token │ └─ rclone verifier → NAS │ +│ changes NOTHING) │ └──────────────────────────────┘ +└──────────────────────────┘ +``` + +--- + +## Part A — Your machine (collector side) + +Already running via `docker compose up -d` in this repo. To prepare it for +remote agents: + +**A1. Set a real agent token.** In `.env`, replace the dev token: +```bash +# generate one (WSL): openssl rand -hex 24 +AGENT_TOKEN= +``` +Apply it: `wsl -e bash -c "cd /mnt/c/Users/seekr/OneDrive/Desktop/work/R-clone && docker compose up -d collector agent-gcbot agent-riyadh"` +(recreates with the new env — no rebuild needed). + +**A2. Install Tailscale inside WSL** (gives your collector an address his +machine can reach — no router changes, encrypted): +```bash +wsl +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up # opens a login URL — use a free account +tailscale ip -4 # ← note this IP, e.g. 100.101.102.103 +``` + +**A3. Keep it reachable.** The WSL VM must stay up: keep a WSL terminal open +(or add to `C:\Users\seekr\.wslconfig`: `[wsl2]` + `vmIdleTimeout=-1`), and the +laptop must be on. For an always-on setup, move the collector to an office +server or small VPS later — everything is compose, so it's a copy-paste move. + +**A4. Test from your side:** `curl http://localhost:8000/api/health` → `{"ok":true}`. + +--- + +## Part B — His machine (agent side) + +**B1. Send him one folder.** Assemble it from this repo: +``` +agent-deploy/ +├── docker-compose.yml ← deploy/agent-only/docker-compose.yml +├── .env ← deploy/agent-only/.env.example, filled in +└── agent/ + ├── agent.py ← monitor/agent/agent.py + └── Dockerfile ← monitor/agent/Dockerfile +``` +Zip it, send it however you like — **except the AGENT_TOKEN value: share that +separately** (Signal/WhatsApp/verbally), not inside the zip. + +**B2. He installs Tailscale** on his machine and joins **your** tailnet: +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +``` +Easiest: log in with the same account, or send him a share invite from the +Tailscale admin console. + +**B3. He fills `.env`** (values for his machine are pre-filled in the example; +for any other machine, read the paths from the header comment + volumes of that +machine's generated `docker-compose.yml`): +- `MACHINE_ID` — unique, e.g. `JAGAN-TEST-01` +- `COLLECTOR_URL=http://:8000` +- `AGENT_TOKEN` — the value you shared +- the three paths (already correct for his machine) + +**B4. He starts it:** +```bash +cd agent-deploy +docker compose up -d --build +docker logs rclone-agent # expect: [agent] JAGAN-TEST-01 (TEST) -> http://100...:8000 +``` +No errors in that log = done. His existing production containers are untouched — +the agent only reads two folders, both mounted read-only. + +**B5. Verify on your side:** a `JAGAN-TEST-01` card appears on +http://localhost:8000 within ~10 s (heartbeat). Pair rows and file counts fill +in after his next sync round (up to `SYNC_INTERVAL` later). + +--- + +## Part C — Optional: server-side verification for his machine + +Your collector currently verifies uploads against the **local fake NAS** only. +To have it verify his real uploads on the office Synology, the collector needs +the real NAS reachable + its SFTP creds in the collector env +(`SYNOLOGY_HOST/PORT/USER/PASS` in compose). Do this only after the team +rotates the leaked password. Until then his card shows heartbeat, rounds, +synced files, failures — everything except pending/missing. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| No card appears | `docker logs rclone-agent` — "collector unreachable" → check `tailscale status` both sides, `curl http://:8000/api/health` from his machine | +| 401 in agent logs | AGENT_TOKEN mismatch — must be byte-identical both sides | +| Card but no pairs/files | Normal until his next sync round completes; check `tail Sync_logs/sync.log` is actually growing | +| Card goes OFFLINE later | Your WSL idled out or laptop slept (see A3) | +| Wrong/empty pending counts | Paths in his `.env` don't match the sync container's volume host paths | + +## Security notes +- The agent sends outbound HTTP only; it opens no ports on his machine. +- Tailscale traffic is end-to-end encrypted (WireGuard); the token stops + spoofed agents even inside the tailnet. +- Never put real NAS credentials in the agent bundle — the agent doesn't need + them. Only the collector (Part C) ever holds NAS creds. diff --git a/client/GCBOT/master_sheet_GCBOT.csv b/client/GCBOT/master_sheet_GCBOT.csv new file mode 100644 index 0000000..088000d --- /dev/null +++ b/client/GCBOT/master_sheet_GCBOT.csv @@ -0,0 +1,3 @@ +chainage,lat,lon +1.0,24.7,46.6 +2.0,24.8,46.7 diff --git a/client/NIGHT/night_model.pt b/client/NIGHT/night_model.pt new file mode 100644 index 0000000..6881d56 Binary files /dev/null and b/client/NIGHT/night_model.pt differ diff --git a/client/batch_test/video_chunk_01.mp4 b/client/batch_test/video_chunk_01.mp4 new file mode 100644 index 0000000..90ae9a2 Binary files /dev/null and b/client/batch_test/video_chunk_01.mp4 differ diff --git a/client/batch_test/video_chunk_02.mp4 b/client/batch_test/video_chunk_02.mp4 new file mode 100644 index 0000000..d5bdda2 Binary files /dev/null and b/client/batch_test/video_chunk_02.mp4 differ diff --git a/client/batch_test/video_chunk_03.mp4 b/client/batch_test/video_chunk_03.mp4 new file mode 100644 index 0000000..11ec0ff Binary files /dev/null and b/client/batch_test/video_chunk_03.mp4 differ diff --git a/client/batch_test/video_chunk_04.mp4 b/client/batch_test/video_chunk_04.mp4 new file mode 100644 index 0000000..b5c4c07 Binary files /dev/null and b/client/batch_test/video_chunk_04.mp4 differ diff --git a/client/batch_test/video_chunk_05.mp4 b/client/batch_test/video_chunk_05.mp4 new file mode 100644 index 0000000..c55a5a5 Binary files /dev/null and b/client/batch_test/video_chunk_05.mp4 differ diff --git a/client/batch_test/video_chunk_06.mp4 b/client/batch_test/video_chunk_06.mp4 new file mode 100644 index 0000000..19ec33c Binary files /dev/null and b/client/batch_test/video_chunk_06.mp4 differ diff --git a/client/batch_test/video_chunk_07.mp4 b/client/batch_test/video_chunk_07.mp4 new file mode 100644 index 0000000..9626ff2 Binary files /dev/null and b/client/batch_test/video_chunk_07.mp4 differ diff --git a/client/batch_test/video_chunk_08.mp4 b/client/batch_test/video_chunk_08.mp4 new file mode 100644 index 0000000..0523e38 Binary files /dev/null and b/client/batch_test/video_chunk_08.mp4 differ diff --git a/client/batch_test/video_chunk_09.mp4 b/client/batch_test/video_chunk_09.mp4 new file mode 100644 index 0000000..417edc0 Binary files /dev/null and b/client/batch_test/video_chunk_09.mp4 differ diff --git a/client/batch_test/video_chunk_10.mp4 b/client/batch_test/video_chunk_10.mp4 new file mode 100644 index 0000000..deb0efb Binary files /dev/null and b/client/batch_test/video_chunk_10.mp4 differ diff --git a/client/batch_test/video_chunk_11.mp4 b/client/batch_test/video_chunk_11.mp4 new file mode 100644 index 0000000..6a27edc Binary files /dev/null and b/client/batch_test/video_chunk_11.mp4 differ diff --git a/client/batch_test/video_chunk_12.mp4 b/client/batch_test/video_chunk_12.mp4 new file mode 100644 index 0000000..8b22de6 Binary files /dev/null and b/client/batch_test/video_chunk_12.mp4 differ diff --git a/client/batch_test/video_chunk_13.mp4 b/client/batch_test/video_chunk_13.mp4 new file mode 100644 index 0000000..69079cc Binary files /dev/null and b/client/batch_test/video_chunk_13.mp4 differ diff --git a/client/batch_test/video_chunk_14.mp4 b/client/batch_test/video_chunk_14.mp4 new file mode 100644 index 0000000..a028f98 Binary files /dev/null and b/client/batch_test/video_chunk_14.mp4 differ diff --git a/client/batch_test/video_chunk_15.mp4 b/client/batch_test/video_chunk_15.mp4 new file mode 100644 index 0000000..3df9fe2 Binary files /dev/null and b/client/batch_test/video_chunk_15.mp4 differ diff --git a/client/batch_test/video_chunk_16.mp4 b/client/batch_test/video_chunk_16.mp4 new file mode 100644 index 0000000..2c70fb7 Binary files /dev/null and b/client/batch_test/video_chunk_16.mp4 differ diff --git a/client/batch_test/video_chunk_17.mp4 b/client/batch_test/video_chunk_17.mp4 new file mode 100644 index 0000000..bbb5708 Binary files /dev/null and b/client/batch_test/video_chunk_17.mp4 differ diff --git a/client/batch_test/video_chunk_18.mp4 b/client/batch_test/video_chunk_18.mp4 new file mode 100644 index 0000000..cf717c2 Binary files /dev/null and b/client/batch_test/video_chunk_18.mp4 differ diff --git a/client/batch_test/video_chunk_19.mp4 b/client/batch_test/video_chunk_19.mp4 new file mode 100644 index 0000000..56e62bc Binary files /dev/null and b/client/batch_test/video_chunk_19.mp4 differ diff --git a/client/batch_test/video_chunk_20.mp4 b/client/batch_test/video_chunk_20.mp4 new file mode 100644 index 0000000..14f463d Binary files /dev/null and b/client/batch_test/video_chunk_20.mp4 differ diff --git a/client/batch_test/video_chunk_21.mp4 b/client/batch_test/video_chunk_21.mp4 new file mode 100644 index 0000000..526b653 Binary files /dev/null and b/client/batch_test/video_chunk_21.mp4 differ diff --git a/client/batch_test/video_chunk_22.mp4 b/client/batch_test/video_chunk_22.mp4 new file mode 100644 index 0000000..a609f0a Binary files /dev/null and b/client/batch_test/video_chunk_22.mp4 differ diff --git a/client/batch_test/video_chunk_23.mp4 b/client/batch_test/video_chunk_23.mp4 new file mode 100644 index 0000000..22757c2 Binary files /dev/null and b/client/batch_test/video_chunk_23.mp4 differ diff --git a/client/batch_test/video_chunk_24.mp4 b/client/batch_test/video_chunk_24.mp4 new file mode 100644 index 0000000..03771da Binary files /dev/null and b/client/batch_test/video_chunk_24.mp4 differ diff --git a/client/batch_test/video_chunk_25.mp4 b/client/batch_test/video_chunk_25.mp4 new file mode 100644 index 0000000..5ec1d73 Binary files /dev/null and b/client/batch_test/video_chunk_25.mp4 differ diff --git a/client/batch_test/video_chunk_26.mp4 b/client/batch_test/video_chunk_26.mp4 new file mode 100644 index 0000000..170b1e7 Binary files /dev/null and b/client/batch_test/video_chunk_26.mp4 differ diff --git a/client/batch_test/video_chunk_27.mp4 b/client/batch_test/video_chunk_27.mp4 new file mode 100644 index 0000000..e75bced Binary files /dev/null and b/client/batch_test/video_chunk_27.mp4 differ diff --git a/client/batch_test/video_chunk_28.mp4 b/client/batch_test/video_chunk_28.mp4 new file mode 100644 index 0000000..9ff5042 Binary files /dev/null and b/client/batch_test/video_chunk_28.mp4 differ diff --git a/client/batch_test/video_chunk_29.mp4 b/client/batch_test/video_chunk_29.mp4 new file mode 100644 index 0000000..72ddcbb Binary files /dev/null and b/client/batch_test/video_chunk_29.mp4 differ diff --git a/client/batch_test/video_chunk_30.mp4 b/client/batch_test/video_chunk_30.mp4 new file mode 100644 index 0000000..fac899a Binary files /dev/null and b/client/batch_test/video_chunk_30.mp4 differ diff --git a/client/yolov8/yolov8n.pt b/client/yolov8/yolov8n.pt new file mode 100644 index 0000000..9cab790 Binary files /dev/null and b/client/yolov8/yolov8n.pt differ diff --git a/deploy/agent-only/.env.example b/deploy/agent-only/.env.example new file mode 100644 index 0000000..ac01e4e --- /dev/null +++ b/deploy/agent-only/.env.example @@ -0,0 +1,19 @@ +# Copy to .env and fill in. Values below match JAGAN's test machine — +# adjust PRIMARY/SECONDARY paths for other machines (see their generated +# docker-compose.yml header comments). + +# identity shown on the dashboard — must be unique per machine +MACHINE_ID=JAGAN-TEST-01 +SITE=TEST + +# where the monitoring collector is reachable FROM THIS MACHINE +# (Tailscale IP of the collector's host, port 8000) +COLLECTOR_URL=http://100.x.y.z:8000 + +# shared secret — must exactly match AGENT_TOKEN on the collector +AGENT_TOKEN=change-me + +# paths from this machine's existing docker-compose.yml (rclone-synology-sync volumes) +SYNC_LOGS_DIR=/home/testing/JAGAN/Prerequisites/Sync_logs +IMAGE_DIR=/home/testing/JAGAN/Prerequisites/TAKELEAP/image_root_dir +CSV_DIR=/home/testing/JAGAN/Prerequisites/csv_files diff --git a/deploy/agent-only/docker-compose.yml b/deploy/agent-only/docker-compose.yml new file mode 100644 index 0000000..2b730fc --- /dev/null +++ b/deploy/agent-only/docker-compose.yml @@ -0,0 +1,30 @@ +# ============================================================================= +# AGENT-ONLY BUNDLE — for a machine that ALREADY runs the production +# rclone-synology-sync container (e.g. JAGAN's test machine). +# Adds monitoring only; touches nothing in the existing stack. +# +# 1. Copy this folder + the repo's monitor/agent/ folder to the machine: +# agent-deploy/ +# ├── docker-compose.yml (this file) +# ├── .env (from .env.example) +# └── agent/ (= monitor/agent/: agent.py + Dockerfile) +# 2. Fill .env +# 3. docker compose up -d --build +# ============================================================================= +services: + rclone-agent: + build: ./agent + container_name: rclone-agent + environment: + - MACHINE_ID=${MACHINE_ID} + - SITE=${SITE} + - COLLECTOR_URL=${COLLECTOR_URL} + - AGENT_TOKEN=${AGENT_TOKEN} + - WATCH_DIRS=/sources/image_root_dir,/sources/csv_files + volumes: + # the EXISTING sync container's log folder (read-only) + - ${SYNC_LOGS_DIR}:/logs:ro + # the up-pair source folders (read-only) — enables pending/missing detection + - ${IMAGE_DIR}:/sources/image_root_dir:ro + - ${CSV_DIR}:/sources/csv_files:ro + restart: unless-stopped diff --git a/deploy/remote-machine/.env.example b/deploy/remote-machine/.env.example new file mode 100644 index 0000000..532d937 --- /dev/null +++ b/deploy/remote-machine/.env.example @@ -0,0 +1,23 @@ +# Copy to .env and fill in. NEVER commit the filled-in version. + +# --- identity shown in the dashboard --- +MACHINE_ID=FRIEND-01 +SITE=FRIEND + +# --- where the algorithm writes its results on this machine --- +OUTPUT_DIR=/home/friend/algorithm/output + +# --- Synology / SFTP target (get real values from the team; do not reuse demo ones) --- +SYNOLOGY_HOST=your-nas-hostname +SYNOLOGY_PORT=22 +SYNOLOGY_USER=upload-user +SYNOLOGY_PASS=change-me +REMOTE_BASE=ClientSync/Results + +# --- monitoring --- +# The collector's address AS REACHABLE FROM THIS MACHINE. +# Same LAN: http://:8000 · over internet: use Tailscale/VPN IP +COLLECTOR_URL=http://100.0.0.0:8000 +# Must match AGENT_TOKEN on the collector +AGENT_TOKEN=change-me +RC_PASSWORD=change-me diff --git a/deploy/remote-machine/docker-compose.yml b/deploy/remote-machine/docker-compose.yml new file mode 100644 index 0000000..ed2ff7e --- /dev/null +++ b/deploy/remote-machine/docker-compose.yml @@ -0,0 +1,43 @@ +# ============================================================================= +# REMOTE MACHINE BUNDLE — run on any machine that should appear in the fleet UI +# (e.g. a friend's system running his algorithm and syncing results). +# +# Setup on the remote machine: +# 1. Copy the repo's `sync-container/` and `monitor/agent/` folders next to +# this file (or copy the whole repo and use this file from deploy/remote-machine). +# 2. Fill in .env (see .env.example). +# 3. Put the algorithm's output folder in OUTPUT_DIR — anything written there +# is synced up and monitored. +# 4. docker compose up -d --build +# ============================================================================= +services: + rclone-sync: + build: ../../sync-container + container_name: rclone-sync + environment: + - SYNOLOGY_HOST=${SYNOLOGY_HOST} + - SYNOLOGY_PORT=${SYNOLOGY_PORT} + - SYNOLOGY_USER=${SYNOLOGY_USER} + - SYNOLOGY_PASS=${SYNOLOGY_PASS} + - RC_PASSWORD=${RC_PASSWORD} + - SYNC_INTERVAL=120 + # results go UP to the server under this machine's own path + - SYNC_1=up:/sources/results:${REMOTE_BASE}/${MACHINE_ID} + volumes: + - ${OUTPUT_DIR}:/sources/results:ro + - ./Sync_logs:/logs + restart: unless-stopped + + agent: + build: ../../monitor/agent + container_name: rclone-agent + environment: + - MACHINE_ID=${MACHINE_ID} + - SITE=${SITE} + - COLLECTOR_URL=${COLLECTOR_URL} # e.g. http://100.x.y.z:8000 (Tailscale IP) + - AGENT_TOKEN=${AGENT_TOKEN} + - WATCH_DIRS=/sources/results + volumes: + - ./Sync_logs:/logs:ro + - ${OUTPUT_DIR}:/sources/results:ro + restart: unless-stopped diff --git a/docker-compose.sim.yml b/docker-compose.sim.yml new file mode 100644 index 0000000..ebb6f80 --- /dev/null +++ b/docker-compose.sim.yml @@ -0,0 +1,130 @@ +# ============================================================================= +# SIMULATION LAB — fake NAS + 2 fake client machines + their agents. +# NOT started by plain `docker compose up -d` anymore. +# +# To run the simulator (for dev/testing): +# docker compose -f docker-compose.yml -f docker-compose.sim.yml up -d +# To stop just the sims: +# docker compose -f docker-compose.yml -f docker-compose.sim.yml stop \ +# synology rclone-synology-sync file-generator rclone-sync-riyadh \ +# file-generator-riyadh agent-gcbot agent-riyadh +# ============================================================================= +services: + + synology: + image: atmoz/sftp:alpine + container_name: fake-synology + command: "sr-upload:${SYNOLOGY_PASS}:1001::upload" + ports: + - "2222:22" + volumes: + - ./server-data:/home/sr-upload/upload + restart: unless-stopped + + rclone-synology-sync: + build: ./sync-container + container_name: rclone-synology-sync + depends_on: + - synology + ports: + - "5572:5572" + environment: + - SYNOLOGY_HOST=synology + - SYNOLOGY_PORT=22 + - SYNOLOGY_USER=sr-upload + - SYNOLOGY_PASS=${SYNOLOGY_PASS} + - RC_PASSWORD=${RC_PASSWORD} + - SYNC_INTERVAL=60 + - RCLONE_SFTP_SET_MODTIME=false + - SYNC_1=up:/sources/image_root_dir:upload/Saudi_Video_Sync/SeekRight/Anomaly/${ORGANISATION}/${SITENAME}/TEST + - SYNC_2=down:/sources/${SITENAME}:upload/ClientSync/ALGORITHM_DEPLOYMENT/Master_Sheets/${ORGANISATION}/${SITENAME} + - SYNC_3=down:/sources/yolo:upload/ClientSync/ALGORITHM_DEPLOYMENT/Models/yolov8 + - SYNC_4=down:/sources/NIGHT:upload/ClientSync/ALGORITHM_DEPLOYMENT/Models/NIGHT + - SYNC_5=up:/sources/csv_files:upload/ClientSync/ALGORITHM_DEPLOYMENT/csv_files/${ORGANISATION}/${SITENAME} + volumes: + - ./client/image_root_dir:/sources/image_root_dir:ro + - ./client/${SITENAME}:/sources/${SITENAME} + - ./client/yolov8:/sources/yolo + - ./client/NIGHT:/sources/NIGHT + - ./client/csv_files:/sources/csv_files:ro + - ./client/Sync_logs:/logs + - ./client/batch_test:/sources/batch_test:ro + restart: unless-stopped + + file-generator: + image: alpine:latest + container_name: fake-algorithm + volumes: + - ./generator/generate.sh:/generate.sh:ro + - ./client/image_root_dir:/out/image_root_dir + - ./client/csv_files:/out/csv_files + environment: + - INTERVAL=45 + command: ["/bin/sh", "/generate.sh"] + restart: unless-stopped + + rclone-sync-riyadh: + build: ./sync-container + container_name: rclone-sync-riyadh + depends_on: + - synology + environment: + - SYNOLOGY_HOST=synology + - SYNOLOGY_PORT=22 + - SYNOLOGY_USER=sr-upload + - SYNOLOGY_PASS=${SYNOLOGY_PASS} + - RC_PASSWORD=${RC_PASSWORD} + - SYNC_INTERVAL=90 + - RCLONE_SFTP_SET_MODTIME=false + - SYNC_1=up:/sources/image_root_dir:upload/Saudi_Video_Sync/SeekRight/Anomaly/${ORGANISATION}/RIYADH/TEST + - SYNC_2=down:/sources/yolo:upload/ClientSync/ALGORITHM_DEPLOYMENT/Models/yolov8 + volumes: + - ./client2/image_root_dir:/sources/image_root_dir:ro + - ./client2/yolov8:/sources/yolo + - ./client2/Sync_logs:/logs + restart: unless-stopped + + file-generator-riyadh: + image: alpine:latest + container_name: fake-algorithm-riyadh + volumes: + - ./generator/generate.sh:/generate.sh:ro + - ./client2/image_root_dir:/out/image_root_dir + - ./client2/csv_files:/out/csv_files + environment: + - INTERVAL=70 + command: ["/bin/sh", "/generate.sh"] + restart: unless-stopped + + agent-gcbot: + build: ./monitor/agent + container_name: agent-gcbot + environment: + - MACHINE_ID=GCBOT-01 + - SITE=GCBOT + - COLLECTOR_URL=http://collector:8000 + - AGENT_TOKEN=${AGENT_TOKEN} + - WATCH_DIRS=/sources/image_root_dir,/sources/csv_files + volumes: + - ./client/Sync_logs:/logs:ro + - ./client/image_root_dir:/sources/image_root_dir:ro + - ./client/csv_files:/sources/csv_files:ro + depends_on: + - collector + restart: unless-stopped + + agent-riyadh: + build: ./monitor/agent + container_name: agent-riyadh + environment: + - MACHINE_ID=RIYADH-01 + - SITE=RIYADH + - COLLECTOR_URL=http://collector:8000 + - AGENT_TOKEN=${AGENT_TOKEN} + - WATCH_DIRS=/sources/image_root_dir + volumes: + - ./client2/Sync_logs:/logs:ro + - ./client2/image_root_dir:/sources/image_root_dir:ro + depends_on: + - collector + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f1b921 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +# ============================================================================= +# REAL MONITORING STACK — collector + database. This is what runs permanently. +# docker compose up -d +# +# The simulation lab (fake NAS + fake machines) lives in docker-compose.sim.yml +# and only starts when explicitly included: +# docker compose -f docker-compose.yml -f docker-compose.sim.yml up -d +# ============================================================================= +services: + + mongo: + image: mongo:7 + container_name: rclonemon-mongo + volumes: + - rclonemon_mongo:/data/db + restart: unless-stopped + + collector: + build: ./monitor/collector + container_name: rclonemon-collector + ports: + - "8000:8000" + environment: + - MONGO_URL=mongodb://mongo:27017 + - AGENT_TOKEN=${AGENT_TOKEN} + # fake NAS (only relevant when the sim lab is running) + - SYNOLOGY_HOST=synology + - SYNOLOGY_PORT=22 + - SYNOLOGY_USER=sr-upload + - SYNOLOGY_PASS=${SYNOLOGY_PASS} + # the REAL office NAS (read-only lsjson) for verifying real machines; + # sites listed in SITE_REMOTES use it, everyone else uses the fake NAS + - REAL_SYNOLOGY_HOST=${REAL_SYNOLOGY_HOST} + - REAL_SYNOLOGY_PORT=${REAL_SYNOLOGY_PORT} + - REAL_SYNOLOGY_USER=${REAL_SYNOLOGY_USER} + - REAL_SYNOLOGY_PASS=${REAL_SYNOLOGY_PASS} + - SITE_REMOTES=TEST=synoreal + - VERIFY_INTERVAL=45 + - MISSING_GRACE_S=600 + - ONLINE_WINDOW_S=60 + # alert if a machine is online but hasn't completed a round in this long + - STALLED_ROUND_S=900 + volumes: + - ./frontend/dist:/app/static:ro + depends_on: + - mongo + restart: unless-stopped + +volumes: + rclonemon_mongo: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3fe9566 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Rclone Fleet Monitor + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..417e3f7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1857 @@ +{ + "name": "rclone-fleet-monitor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rclone-fleet-monitor", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e2618c5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "rclone-fleet-monitor", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..d70203e --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,260 @@ +import React, { useEffect, useState } from "react"; + +const POLL_MS = 4000; + +// status palette (dataviz tokens) — always paired with icon+label, never color alone +const STATUS = { + good: { color: "#0ca30c", icon: "●", label: "online" }, + warning: { color: "#fab219", icon: "▲", label: "warning" }, + serious: { color: "#ec835a", icon: "▲", label: "failing" }, + critical: { color: "#d03b3b", icon: "■", label: "offline" }, +}; + +function fmtBytes(b) { + if (b == null) return ""; + if (b >= 1e9) return `${(b / 1e9).toFixed(1)} GB`; + if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; + if (b >= 1e3) return `${(b / 1e3).toFixed(0)} KB`; + return `${b} B`; +} + +function fmtDur(s) { + if (s == null) return ""; + if (s >= 3600) return `~${(s / 3600).toFixed(1)}h`; + if (s >= 60) return `~${Math.round(s / 60)}m`; + return `~${Math.round(s)}s`; +} + +function ago(iso) { + if (!iso) return "never"; + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (s < 60) return `${Math.round(s)}s ago`; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + return `${(s / 3600).toFixed(1)}h ago`; +} + +function StatusBadge({ kind, text }) { + const s = STATUS[kind]; + return ( + + + {text ?? s.label} + + ); +} + +function PairRow({ p }) { + const kind = p.last_status === "fail" ? "serious" : p.last_status === "ok" ? "good" : "warning"; + const arrow = p.direction === "up" ? "↑" : "↓"; + // pair labels look like "/sources/x → server:path" — keep them compact + const label = (p.pair || "").split("→")[0].trim().replace("server:", ""); + return ( +
+ {arrow} {p.direction} + {label} + + {p.pending_count > 0 && ( + + {p.pending_count} pending{p.pending_bytes > 0 && ` · ${fmtBytes(p.pending_bytes)}`} + + )} + {p.missing?.length > 0 && ( + + {p.missing.length} missing! + + )} + + +
+ ); +} + +function MachineCard({ m, selected, onSelect }) { + const kind = m.online ? "good" : "critical"; + return ( +
onSelect(m.machine_id)}> +
+
+
{m.machine_id}
+
site {m.site} · seen {ago(m.last_seen)}
+
+ +
+ + {!m.progress && m.last_transfer && ( +
+ last transfer · {m.last_transfer.files} files ·{" "} + {fmtBytes(m.last_transfer.bytes)} in{" "} + {fmtDur(m.last_transfer.duration_s).replace("~", "")} + {m.last_transfer.avg_bps != null && ( + <> · {fmtBytes(m.last_transfer.avg_bps)}/s + )} +
+ )} + + {m.progress && ( +
+ {m.progress.percent}% · {m.progress.speed} · ETA{" "} + {m.progress.eta} ({m.progress.direction} sync) +
+
+
+
+ )} + +
+
+
{m.last_round ?? "–"}
+
last round · {ago(m.last_round_at)}
+
+
+
{m.files_1h}
+
files synced (1h)
+
+
+
0 ? { color: STATUS.warning.color } : {}}> + {m.pending_total ?? "–"} +
+
+ pending → server + {m.pending_total > 0 && m.pending_bytes != null && ` · ${fmtBytes(m.pending_bytes)}`} + {m.pending_total > 0 && m.backlog_eta_s != null && ` · ${fmtDur(m.backlog_eta_s)}`} +
+
+
+
+ {m.errors_1h} +
+
errors (1h)
+
+
+ +
{m.pairs.map((p) => )}
+
+ ); +} + +function EventsPanel({ machineId }) { + const [tab, setTab] = useState("file_synced"); + const [events, setEvents] = useState([]); + + useEffect(() => { + if (!machineId) return; + let stop = false; + const load = () => + fetch(`/api/machines/${machineId}/events?type=${tab}&limit=40`) + .then((r) => r.json()) + .then((d) => !stop && setEvents(d.events)) + .catch(() => {}); + load(); + const t = setInterval(load, POLL_MS); + return () => { stop = true; clearInterval(t); }; + }, [machineId, tab]); + + if (!machineId) return null; + + const TABS = [ + ["file_synced", "Files"], + ["error", "Errors"], + ["round_summary", "Rounds"], + ["pair_ok,pair_fail", "Pair results"], + ]; + + return ( +
+
+

{machineId} — recent activity

+
+ {TABS.map(([k, label]) => ( + + ))} +
+
+ + + + + + {events.map((e, i) => ( + + + + + + ))} + {events.length === 0 && ( + + )} + +
whentypedetail
{ago(e.received_at)}{e.type} + {e.type === "file_synced" && `${e.file} — ${e.action} (${e.direction ?? "?"} · ${e.pair ?? ""})`} + {e.type === "error" && e.message} + {e.type === "round_summary" && + (e.files + ? `round #${e.round} — ${e.files} file(s), ${fmtBytes(e.bytes)} in ` + + `${fmtDur(e.duration_s).replace("~", "")}` + + (e.avg_bps != null ? ` · ${fmtBytes(e.avg_bps)}/s` : "") + : `round #${e.round} — no transfers (${fmtDur(e.duration_s).replace("~", "")})`)} + {(e.type === "round_start" || e.type === "round_end") && `round #${e.round} @ ${e.log_ts}`} + {(e.type === "pair_ok" || e.type === "pair_fail") && + `${e.pair}${e.exit_code != null ? ` — exit ${e.exit_code}` : " — ok"}`} +
no events yet — waiting for the next sync round
+
+ ); +} + +export default function App() { + const [data, setData] = useState(null); + const [selected, setSelected] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let stop = false; + const load = () => + fetch("/api/overview") + .then((r) => r.json()) + .then((d) => { if (!stop) { setData(d); setErr(null); } }) + .catch((e) => !stop && setErr(String(e))); + load(); + const t = setInterval(load, POLL_MS); + return () => { stop = true; clearInterval(t); }; + }, []); + + const machines = data?.machines ?? []; + const alerts = data?.alerts ?? []; + const online = machines.filter((m) => m.online).length; + + return ( +
+
+

Rclone Fleet Monitor

+
+ {online}/{machines.length} machines online +
+
+ + {err &&
collector unreachable: {err}
} + + {alerts.map((a, i) => ( +
+ + {a.severity.toUpperCase()} · {a.machine_id} — {a.message} +
+ ))} + +
+ {machines.map((m) => ( + + ))} + {machines.length === 0 && !err &&
no machines reporting yet…
} +
+ + +
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..7cbccbf --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..4071837 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,95 @@ +/* dark theme — dataviz tokens: surface #1a1a19, text #fff / #c3c2b7 */ +* { box-sizing: border-box; margin: 0; } +:root { + --surface: #1a1a19; + --surface-2: #232322; + --border: #33332f; + --text: #ffffff; + --text-2: #c3c2b7; + --accent: #3987e5; + --good: #0ca30c; + --warning: #fab219; + --serious: #ec835a; + --critical: #d03b3b; +} +body { + background: var(--surface); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; +} +.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-variant-numeric: tabular-nums; } +.app { max-width: 1180px; margin: 0 auto; padding: 20px 16px 60px; } + +header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; } +h1 { font-size: 20px; font-weight: 650; } +.header-stats { color: var(--text-2); } + +.alert { + display: flex; gap: 8px; align-items: baseline; + background: var(--surface-2); border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; padding: 8px 12px; margin-bottom: 8px; color: var(--text-2); +} +.alert strong { color: var(--text); } +.alert-critical { border-left-color: var(--critical); } +.alert-critical > span:first-child { color: var(--critical); } +.alert-serious { border-left-color: var(--serious); } +.alert-serious > span:first-child { color: var(--serious); } +.alert-warning { border-left-color: var(--warning); } +.alert-warning > span:first-child { color: var(--warning); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; margin-top: 12px; } + +.card { + background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; + padding: 14px; cursor: pointer; transition: border-color 0.15s; +} +.card:hover { border-color: #4a4a44; } +.card-selected { border-color: var(--accent); } +.card-head { display: flex; justify-content: space-between; align-items: start; } +.card-title { font-size: 16px; font-weight: 650; } +.card-sub { color: var(--text-2); font-size: 12px; } + +.badge { display: inline-flex; gap: 5px; align-items: baseline; font-size: 12px; font-weight: 600; } +.badge-text { color: var(--text-2); font-weight: 500; } + +.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 12px 0; } +.pending-chip, .missing-chip { + font-size: 11px; padding: 1px 6px; border-radius: 8px; margin-right: 6px; +} +.pending-chip { color: var(--warning); border: 1px solid var(--warning); } +.missing-chip { color: var(--critical); border: 1px solid var(--critical); font-weight: 700; } +.tile { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; } +.tile-num { font-size: 22px; font-weight: 650; } +.tile-label { color: var(--text-2); font-size: 11px; margin-top: 2px; } + +.pairs { display: flex; flex-direction: column; gap: 4px; } +.pair-row { + display: grid; grid-template-columns: 62px 1fr auto; gap: 8px; align-items: baseline; + font-size: 12px; padding: 3px 0; border-top: 1px solid var(--border); +} +.pair-dir { color: var(--text-2); } +.pair-label { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.progress-line { margin: 10px 0 2px; font-size: 12px; color: var(--text-2); } +.progress-track { height: 4px; background: var(--surface); border-radius: 2px; margin-top: 6px; overflow: hidden; } +.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.5s; } + +.panel { margin-top: 24px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 14px; } +.panel-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; } +.panel h2 { font-size: 15px; font-weight: 650; } +.tabs { display: flex; gap: 6px; } +.tab { + background: var(--surface); color: var(--text-2); border: 1px solid var(--border); + border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer; +} +.tab-on { color: var(--text); border-color: var(--accent); } + +table.events { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12.5px; } +.events th { + text-align: left; color: var(--text-2); font-weight: 500; font-size: 11px; + border-bottom: 1px solid var(--border); padding: 4px 8px; +} +.events td { padding: 5px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +.events td.detail { color: var(--text-2); word-break: break-all; } +.empty { color: var(--text-2); padding: 16px; text-align: center; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..540fdfa --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + // dev mode: `npm run dev` proxies API calls to the collector container + proxy: { "/api": "http://localhost:8000" }, + }, +}); diff --git a/generator/generate.sh b/generator/generate.sh new file mode 100644 index 0000000..d4d09fc --- /dev/null +++ b/generator/generate.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Fake "algorithm3" — produces files the way the real GPU container would: +# a processed anomaly image + a results CSV every $INTERVAL seconds. +INTERVAL="${INTERVAL:-45}" +mkdir -p /out/image_root_dir /out/csv_files + +N=0 +while true; do + N=$((N + 1)) + TS=$(date '+%Y%m%d_%H%M%S') + + # Fake "image": random bytes, 100–500 KB, so transfers are visible in stats + SIZE_KB=$(( (N * 97) % 400 + 100 )) + dd if=/dev/urandom of="/out/image_root_dir/anomaly_${TS}.jpg" \ + bs=1024 count="$SIZE_KB" 2>/dev/null + + # Fake results CSV + { + echo "frame,chainage,class,confidence,timestamp" + i=0 + while [ $i -lt 5 ]; do + i=$((i + 1)) + echo "${N},${i}.$((N % 10)),pothole,0.$((70 + i)),${TS}" + done + } > "/out/csv_files/results_${TS}.csv" + + echo "[generator] #${N}: anomaly_${TS}.jpg (${SIZE_KB}KB) + results_${TS}.csv" + sleep "$INTERVAL" +done diff --git a/lan-forward.ps1 b/lan-forward.ps1 new file mode 100644 index 0000000..af52e2b --- /dev/null +++ b/lan-forward.ps1 @@ -0,0 +1,20 @@ +# Forwards Windows :8000 -> WSL collector, so LAN machines can reach the dashboard. +# RUN AS ADMINISTRATOR. Re-run after every Windows reboot (WSL's IP changes). +# Later: nginx replaces this by listening on Windows/LAN directly and proxying +# to the same target. + +$wslIp = (wsl hostname -I).Trim().Split()[0] +Write-Host "WSL IP is $wslIp" + +# refresh the port forward (delete old rule if present, then add) +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 2>$null +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=$wslIp connectport=8000 + +# firewall rule (only created once) +if (-not (Get-NetFirewallRule -DisplayName "Rclone Monitor 8000" -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName "Rclone Monitor 8000" -Direction Inbound -Protocol TCP -LocalPort 8000 -Action Allow | Out-Null + Write-Host "Firewall rule created." +} + +netsh interface portproxy show v4tov4 +Write-Host "`nDone. Dashboard now reachable on this PC's LAN IP, port 8000." diff --git a/monitor/agent/Dockerfile b/monitor/agent/Dockerfile new file mode 100644 index 0000000..cce9a50 --- /dev/null +++ b/monitor/agent/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-alpine +COPY agent.py /agent.py +CMD ["python", "-u", "/agent.py"] diff --git a/monitor/agent/agent.py b/monitor/agent/agent.py new file mode 100644 index 0000000..a9792cc --- /dev/null +++ b/monitor/agent/agent.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Per-machine monitoring agent (sidecar container). + +Tails the rclone sync container's /logs/sync.log (mounted read-only), parses it +into structured events, and POSTs them to the central collector every few +seconds. The POST itself doubles as the machine's heartbeat — an empty event +list still proves the machine (and this agent) is alive. + +Works against the byte-identical production sync.sh: no changes to the sync +container are required. Python stdlib only — no pip installs. + +Env: + MACHINE_ID unique id for this machine (e.g. GCBOT-01) + SITE site/org label (e.g. GCBOT) + COLLECTOR_URL e.g. http://collector:8000 + SYNC_LOG default /logs/sync.log + POLL_SECONDS default 5 +""" +import json +import os +import re +import time +import urllib.request + +MACHINE_ID = os.environ.get("MACHINE_ID", "unknown") +SITE = os.environ.get("SITE", "unknown") +COLLECTOR = os.environ.get("COLLECTOR_URL", "http://collector:8000").rstrip("/") +SYNC_LOG = os.environ.get("SYNC_LOG", "/logs/sync.log") +POLL = float(os.environ.get("POLL_SECONDS", "5")) +AGENT_TOKEN = os.environ.get("AGENT_TOKEN", "") # must match the collector's +# Source dirs of the UP pairs, mounted ro at the SAME paths the sync container +# uses (e.g. /sources/csv_files) so inventory keys match pair labels. The +# collector diffs this against the server listing => pending/missing files. +WATCH_DIRS = [d for d in os.environ.get("WATCH_DIRS", "").split(",") if d] +MAX_INV_FILES = 2000 # safety cap per dir + + +def take_inventory(): + inv = {} + for d in WATCH_DIRS: + files = [] + try: + for root, _, names in os.walk(d): + for n in names: + p = os.path.join(root, n) + try: + st = os.stat(p) + except OSError: + continue + files.append({"name": os.path.relpath(p, d).replace("\\", "/"), + "size": st.st_size, "mtime": st.st_mtime}) + if len(files) >= MAX_INV_FILES: + break + except OSError: + pass + inv[d] = files + return inv + +# --- line patterns for sync.sh's log format --------------------------------- +RE_ROUND_START = re.compile(r"^=== Round #(\d+) started at (.+) ===") +RE_ROUND_DONE = re.compile(r"^=== Round #(\d+) done at (.+) ===") +RE_PAIR_START = re.compile(r"^--- \[(\S+ (?:UP|DOWN))\] (.+?) ---$") +RE_PAIR_OK = re.compile(r"^OK: \[(\S+ (?:UP|DOWN))\] (.+)$") +RE_PAIR_FAIL = re.compile(r"^ERROR: \[(\S+ (?:UP|DOWN))\] (.+) failed \(exit (\d+)\)$") +RE_COPIED = re.compile(r"^([\d/]+ [\d:]+) INFO\s+: (.+?): ((?:Copied|Moved).*)$") +RE_RCLONE_ERR = re.compile(r"^([\d/]+ [\d:]+) ERROR\s*: (.*)$") +# one-line stats: "2026/08/05 10:48:22 INFO : 8.5 MiB / 157 MiB, 5%, 4.2 MiB/s, ETA 32s" +RE_STATS = re.compile( + r"^([\d/]+ [\d:]+) INFO\s+:\s+(\S+ \S*B) / (\S+ \S*B), (\d+)%, (\S+ ?\S*B/s), ETA (\S+)" +) + + +class LogParser: + """Stateful line parser: remembers current round/pair for event context.""" + + def __init__(self): + self.round = None + self.pair = None # current pair label + self.direction = None # "up" | "down" + + def _dir(self, arrow): + return "up" if "UP" in arrow else "down" + + def feed(self, line): + """Return an event dict for this line, or None if it's noise.""" + line = line.rstrip("\r\n") + ctx = {"round": self.round, "pair": self.pair, "direction": self.direction} + + m = RE_ROUND_START.match(line) + if m: + self.round = int(m.group(1)) + self.pair = self.direction = None + return {"type": "round_start", "round": self.round, "log_ts": m.group(2)} + + m = RE_ROUND_DONE.match(line) + if m: + self.pair = self.direction = None + return {"type": "round_end", "round": int(m.group(1)), "log_ts": m.group(2)} + + m = RE_PAIR_START.match(line) + if m: + self.direction = self._dir(m.group(1)) + self.pair = m.group(2) + return {"type": "pair_start", "round": self.round, + "pair": self.pair, "direction": self.direction} + + m = RE_PAIR_OK.match(line) + if m: + return {"type": "pair_ok", "round": self.round, + "pair": m.group(2), "direction": self._dir(m.group(1))} + + m = RE_PAIR_FAIL.match(line) + if m: + return {"type": "pair_fail", "round": self.round, "pair": m.group(2), + "direction": self._dir(m.group(1)), "exit_code": int(m.group(3))} + + m = RE_COPIED.match(line) + if m: + return {"type": "file_synced", "log_ts": m.group(1), "file": m.group(2), + "action": m.group(3), **ctx} + + m = RE_STATS.match(line) + if m: + return {"type": "progress", "log_ts": m.group(1), "done": m.group(2), + "total": m.group(3), "percent": int(m.group(4)), + "speed": m.group(5), "eta": m.group(6), **ctx} + + m = RE_RCLONE_ERR.match(line) + if m: + return {"type": "error", "log_ts": m.group(1), "message": m.group(2), **ctx} + + return None + + +def post_events(events): + payload = { + "machine_id": MACHINE_ID, + "site": SITE, + "sent_at": time.time(), + "events": events, + } + if WATCH_DIRS: + payload["inventory"] = take_inventory() + headers = {"Content-Type": "application/json"} + if AGENT_TOKEN: + headers["Authorization"] = f"Bearer {AGENT_TOKEN}" + req = urllib.request.Request( + COLLECTOR + "/api/ingest", + data=json.dumps(payload).encode(), + headers=headers, + ) + urllib.request.urlopen(req, timeout=10).read() + + +def main(): + parser = LogParser() + f = None + offset = 0 + print(f"[agent] {MACHINE_ID} ({SITE}) -> {COLLECTOR}, log={SYNC_LOG}", flush=True) + + while True: + events = [] + try: + if f is None: + if os.path.exists(SYNC_LOG): + f = open(SYNC_LOG, "r", errors="replace") + f.seek(0, os.SEEK_END) # start from "now"; no historical replay + offset = f.tell() + else: + # handle rotation/truncation: file shrank -> reopen from start + size = os.path.getsize(SYNC_LOG) + if size < offset: + f.close() + f = open(SYNC_LOG, "r", errors="replace") + offset = 0 + for line in f: + ev = parser.feed(line) + if ev: + events.append(ev) + offset = f.tell() + except OSError as e: + print(f"[agent] log read error: {e}", flush=True) + f = None + + try: + post_events(events) # empty list == pure heartbeat + except Exception as e: + print(f"[agent] collector unreachable: {e}", flush=True) + + time.sleep(POLL) + + +if __name__ == "__main__": + main() diff --git a/monitor/collector/Dockerfile b/monitor/collector/Dockerfile new file mode 100644 index 0000000..5357a03 --- /dev/null +++ b/monitor/collector/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends rclone \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir fastapi uvicorn pymongo +COPY main.py start.sh ./ +RUN chmod +x start.sh && sed -i 's/\r$//' start.sh +CMD ["/bin/sh", "/app/start.sh"] diff --git a/monitor/collector/main.py b/monitor/collector/main.py new file mode 100644 index 0000000..9733503 --- /dev/null +++ b/monitor/collector/main.py @@ -0,0 +1,341 @@ +""" +Central collector for the rclone fleet monitor. + +Agents POST /api/ingest every few seconds (their heartbeat + parsed log events). +The React dashboard polls GET /api/overview and per-machine endpoints. + +Storage: MongoDB + machines one doc per machine — last_seen, current round, live progress + pairs one doc per (machine, sync pair) — direction, last status/exit + events append-only event log (rounds, files, errors, pair results) +""" +import json +import os +import re +import subprocess +import threading +import time +from datetime import datetime, timedelta, timezone + +from fastapi import FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pymongo import ASCENDING, DESCENDING, MongoClient + +ONLINE_WINDOW_S = int(os.environ.get("ONLINE_WINDOW_S", "60")) # heartbeat gap before offline +STALLED_ROUND_S = int(os.environ.get("STALLED_ROUND_S", "900")) # online but no round_end for this long +EVENT_KEEP = {"round_start", "round_end", "pair_ok", "pair_fail", + "file_synced", "error"} # progress events stay live-only +LOG_TS_FMT = "%Y/%m/%d %H:%M:%S" + +# tz_aware: give datetimes back with UTC tzinfo, matching what we store +client = MongoClient(os.environ.get("MONGO_URL", "mongodb://mongo:27017"), tz_aware=True) +db = client.rclonemon +db.events.create_index([("machine_id", ASCENDING), ("received_at", DESCENDING)]) +db.events.create_index([("type", ASCENDING), ("received_at", DESCENDING)]) +db.pairs.create_index([("machine_id", ASCENDING), ("pair", ASCENDING)], unique=True) + +app = FastAPI(title="Rclone Fleet Monitor") + +# Agents on other machines (a friend's system, office sites) must present this +# token. Empty AGENT_TOKEN = auth disabled (local dev only). +AGENT_TOKEN = os.environ.get("AGENT_TOKEN", "") + +# Allow an external RMM frontend to call this API from another origin. +app.add_middleware( + CORSMiddleware, + allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","), + allow_methods=["*"], + allow_headers=["*"], +) + + +def now(): + return datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Server-side verification — the audit feature. +# The collector has its OWN rclone remote (written by start.sh). A background +# thread lists every up-pair's remote folder with `rclone lsjson`; the fleet +# view then diffs each machine's local inventory against the server listing: +# pending = file exists locally but not (yet) on the server +# missing = pending AND older than GRACE => should have synced => alert +# --------------------------------------------------------------------------- +VERIFY_ENABLED = bool(os.environ.get("SYNOLOGY_HOST")) +VERIFY_INTERVAL = int(os.environ.get("VERIFY_INTERVAL", "60")) +MISSING_GRACE_S = int(os.environ.get("MISSING_GRACE_S", "600")) +SERVER_LISTINGS = {} # "remote:path" -> {"files": {relpath: size}, "at": datetime} + +# Which rclone remote to list for a given site, e.g. "TEST=synoreal" +# (his real NAS) while everything else defaults to "synodrive" (local fake). +DEFAULT_REMOTE = "synodrive" +SITE_REMOTES = dict(kv.split("=", 1) for kv in + os.environ.get("SITE_REMOTES", "").split(",") if "=" in kv) + + +def _remote_for_site(site): + return SITE_REMOTES.get(site or "", DEFAULT_REMOTE) + + +def _verify_loop(): + while True: + tasks = set() + for p in db.pairs.find({"direction": "up"}): + label = p.get("pair", "") + if "server:" not in label: + continue + m = db.machines.find_one({"machine_id": p["machine_id"]}, {"site": 1}) + remote = _remote_for_site((m or {}).get("site")) + tasks.add((remote, label.split("server:", 1)[1].strip())) + for remote, r in tasks: + try: + out = subprocess.run( + ["rclone", "lsjson", "--recursive", "--files-only", f"{remote}:{r}"], + capture_output=True, timeout=120) + if out.returncode == 0: + files = {f["Path"]: f["Size"] for f in json.loads(out.stdout)} + SERVER_LISTINGS[f"{remote}:{r}"] = {"files": files, "at": now()} + else: + print(f"[verify] {remote}:{r}: exit {out.returncode} " + f"{out.stderr.decode()[:120]}", flush=True) + except Exception as e: + print(f"[verify] {remote}:{r}: {e}", flush=True) + time.sleep(VERIFY_INTERVAL) + + +if VERIFY_ENABLED: + threading.Thread(target=_verify_loop, daemon=True).start() + + +def _speed_bps(s): + """'4.2 MiB/s' -> bytes/second, or None.""" + m = re.match(r"([\d.]+)\s*([KMGT]i?)?B/s", s or "") + if not m: + return None + mult = {"": 1, "K": 1e3, "Ki": 1024, "M": 1e6, "Mi": 1024**2, + "G": 1e9, "Gi": 1024**3, "T": 1e12, "Ti": 1024**4}[m.group(2) or ""] + return float(m.group(1)) * mult + + +def _annotate_pending(m, pairs): + """Attach pending/missing/backlog info to up pairs. + Returns (pending_files, pending_bytes, eta_s) — all None if NO pair could + be verified (so the UI shows '–' instead of a misleading 0).""" + inv = m.get("inventory") or {} + remote_name = _remote_for_site(m.get("site")) + total_pending = 0 + total_bytes = 0 + verified_any = False + for p in pairs: + label = p.get("pair", "") + if p.get("direction") != "up" or "server:" not in label: + continue + local = label.split("→")[0].strip() + rpath = label.split("server:", 1)[1].strip() + listing = SERVER_LISTINGS.get(f"{remote_name}:{rpath}") + files = inv.get(local) + if listing is None or files is None: + continue + verified_any = True + pending = [f for f in files if f["name"] not in listing["files"]] + p["pending_count"] = len(pending) + p["pending_bytes"] = sum(f.get("size", 0) for f in pending) + p["verified_at"] = listing["at"] + p["missing"] = [f["name"] for f in pending + if time.time() - f["mtime"] > MISSING_GRACE_S][:20] + total_pending += len(pending) + total_bytes += p["pending_bytes"] + if not verified_any: + return None, None, None + # backlog ETA: remaining bytes / the transfer speed rclone last reported + bps = _speed_bps((m.get("progress") or {}).get("speed")) + eta_s = int(total_bytes / bps) if bps and bps > 0 and total_bytes > 0 else None + return total_pending, total_bytes, eta_s + + +def require_agent_token(authorization: str | None): + if AGENT_TOKEN and authorization != f"Bearer {AGENT_TOKEN}": + raise HTTPException(401, "missing or invalid agent token") + + +@app.get("/api/health") +def health(): + """Liveness probe for RMM uptime checks: is the collector + DB reachable?""" + db.command("ping") + return {"ok": True, "time": now()} + + +@app.post("/api/ingest") +def ingest(payload: dict, authorization: str | None = Header(None)): + require_agent_token(authorization) + mid = payload.get("machine_id") + if not mid: + raise HTTPException(400, "machine_id required") + ts = now() + machine_set = {"machine_id": mid, "site": payload.get("site"), "last_seen": ts} + if "inventory" in payload: + machine_set["inventory"] = payload["inventory"] + + to_insert = [] + for ev in payload.get("events", []): + etype = ev.get("type") + if etype == "round_start": + machine_set["current_round"] = ev.get("round") + machine_set["round_started_at"] = ts + machine_set["progress"] = None + elif etype == "round_end": + machine_set["last_round"] = ev.get("round") + machine_set["last_round_at"] = ts + machine_set["progress"] = None + elif etype == "progress": + machine_set["progress"] = {k: ev.get(k) for k in + ("pair", "direction", "percent", "speed", "eta", "done", "total")} + elif etype in ("pair_start", "pair_ok", "pair_fail"): + status = {"pair_start": "running", "pair_ok": "ok", "pair_fail": "fail"}[etype] + upd = {"machine_id": mid, "pair": ev.get("pair"), + "direction": ev.get("direction"), "last_status": status, + "updated_at": ts} + if etype == "pair_fail": + upd["last_exit"] = ev.get("exit_code") + upd["last_fail_at"] = ts + elif etype == "pair_ok": + upd["last_exit"] = 0 + upd["last_ok_at"] = ts + db.pairs.update_one({"machine_id": mid, "pair": ev.get("pair")}, + {"$set": upd}, upsert=True) + if etype in EVENT_KEEP: + to_insert.append({**ev, "machine_id": mid, "received_at": ts}) + + db.machines.update_one({"machine_id": mid}, {"$set": machine_set}, upsert=True) + if to_insert: + db.events.insert_many(to_insert) + # Rounds can be far shorter than the poll interval (a 5 GB upload finishing + # in 15s), so live progress is easy to miss entirely. Summarise every + # finished round instead: files, bytes, duration, average throughput. + for ev in payload.get("events", []): + if ev.get("type") == "round_end" and ev.get("round") is not None: + _summarise_round(mid, ev, payload.get("inventory") or {}, ts) + return {"ok": True, "stored": len(to_insert)} + + +def _summarise_round(mid, end_ev, inventory, ts): + rnd = end_ev["round"] + if db.events.find_one({"machine_id": mid, "type": "round_summary", "round": rnd}): + return + sizes = {f["name"]: f.get("size", 0) for files in inventory.values() for f in files} + synced = list(db.events.find({"machine_id": mid, "type": "file_synced", "round": rnd}, + {"file": 1, "direction": 1})) + # only up-pairs have local sizes to look up; downloads land outside WATCH_DIRS + total_bytes = sum(sizes.get(e.get("file"), 0) for e in synced) + + duration = None + start_ev = db.events.find_one({"machine_id": mid, "type": "round_start", "round": rnd}) + try: + t0 = datetime.strptime(start_ev["log_ts"].strip(), LOG_TS_FMT) + t1 = datetime.strptime(end_ev["log_ts"].strip(), LOG_TS_FMT) + duration = (t1 - t0).total_seconds() + except Exception: + pass + + avg_bps = total_bytes / duration if duration and duration > 0 and total_bytes else None + summary = {"type": "round_summary", "machine_id": mid, "round": rnd, + "files": len(synced), "bytes": total_bytes, + "duration_s": duration, "avg_bps": avg_bps, "received_at": ts} + db.events.insert_one(dict(summary)) + if len(synced): # remember the last round that actually moved data + summary.pop("_id", None) + db.machines.update_one({"machine_id": mid}, {"$set": {"last_transfer": summary}}) + + +def _machine_view(m, ts): + last_seen = m.get("last_seen") + online = bool(last_seen) and (ts - last_seen).total_seconds() < ONLINE_WINDOW_S + hour_ago = ts - timedelta(hours=1) + mid = m["machine_id"] + pairs = list(db.pairs.find({"machine_id": mid}, {"_id": 0})) + if VERIFY_ENABLED: + pending_total, pending_bytes, backlog_eta_s = _annotate_pending(m, pairs) + else: + pending_total = pending_bytes = backlog_eta_s = None + return { + "pending_total": pending_total, + "pending_bytes": pending_bytes, + "backlog_eta_s": backlog_eta_s, + "machine_id": mid, + "site": m.get("site"), + "online": online, + "last_seen": last_seen, + "current_round": m.get("current_round"), + "last_round": m.get("last_round"), + "last_round_at": m.get("last_round_at"), + "progress": m.get("progress"), + "last_transfer": m.get("last_transfer"), + "pairs": pairs, + "files_1h": db.events.count_documents( + {"machine_id": mid, "type": "file_synced", "received_at": {"$gt": hour_ago}}), + "errors_1h": db.events.count_documents( + {"machine_id": mid, "type": "error", "received_at": {"$gt": hour_ago}}), + } + + +def _fleet(): + ts = now() + machines = [_machine_view(m, ts) for m in db.machines.find()] + machines.sort(key=lambda m: m["machine_id"]) + + alerts = [] + for m in machines: + if not m["online"]: + alerts.append({"severity": "critical", "machine_id": m["machine_id"], + "message": "machine offline — no heartbeat"}) + for p in m["pairs"]: + if p.get("last_status") == "fail": + alerts.append({"severity": "serious", "machine_id": m["machine_id"], + "message": f"sync pair failing (exit {p.get('last_exit')}): {p['pair']}"}) + if m["online"] and m["errors_1h"] > 0: + alerts.append({"severity": "warning", "machine_id": m["machine_id"], + "message": f"{m['errors_1h']} rclone error(s) in the last hour"}) + for p in m["pairs"]: + if p.get("missing"): + alerts.append({"severity": "serious", "machine_id": m["machine_id"], + "message": f"{len(p['missing'])} file(s) NOT on server after " + f"{MISSING_GRACE_S // 60}min grace: " + f"{', '.join(p['missing'][:3])}…"}) + # "alive but not producing": agent heartbeats fine, but the sync + # container hasn't finished a round in far too long + if m["online"] and m.get("last_round_at"): + stalled = (ts - m["last_round_at"]).total_seconds() + if stalled > STALLED_ROUND_S: + age = f"{stalled / 3600:.1f}h" if stalled >= 3600 else f"{int(stalled // 60)}min" + alerts.append({"severity": "serious", "machine_id": m["machine_id"], + "message": f"agent alive but NO sync round completed in {age} " + f"— sync container stopped or stuck?"}) + return ts, machines, alerts + + +@app.get("/api/overview") +def overview(): + ts, machines, alerts = _fleet() + return {"generated_at": ts, "machines": machines, "alerts": alerts} + + +@app.get("/api/alerts") +def alerts_only(): + """Lightweight endpoint for external RMM polling — alerts, no machine detail.""" + ts, _, alerts = _fleet() + return {"generated_at": ts, "count": len(alerts), "alerts": alerts} + + +@app.get("/api/machines/{machine_id}/events") +def machine_events(machine_id: str, type: str | None = None, limit: int = 50): + q = {"machine_id": machine_id} + if type: + q["type"] = {"$in": type.split(",")} + evs = list(db.events.find(q, {"_id": 0}).sort("received_at", DESCENDING).limit(min(limit, 500))) + return {"machine_id": machine_id, "events": evs} + + +# React build (mounted at /app/static) — must be mounted AFTER the API routes +if os.path.isdir("static"): + app.mount("/", StaticFiles(directory="static", html=True), name="ui") diff --git a/monitor/collector/start.sh b/monitor/collector/start.sh new file mode 100644 index 0000000..bef12eb --- /dev/null +++ b/monitor/collector/start.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Write the collector's own rclone remote (for server-side verification), +# same pattern as production sync.sh. Skipped if SYNOLOGY_HOST is unset. +if [ -n "$SYNOLOGY_HOST" ]; then + mkdir -p /root/.config/rclone + cat > /root/.config/rclone/rclone.conf << EOF +[synodrive] +type = sftp +host = ${SYNOLOGY_HOST} +port = ${SYNOLOGY_PORT:-22} +user = ${SYNOLOGY_USER} +pass = $(rclone obscure "${SYNOLOGY_PASS}") +EOF +fi +# Optional second remote: the REAL office NAS, for verifying real machines +if [ -n "$REAL_SYNOLOGY_HOST" ]; then + mkdir -p /root/.config/rclone + cat >> /root/.config/rclone/rclone.conf << EOF + +[synoreal] +type = sftp +host = ${REAL_SYNOLOGY_HOST} +port = ${REAL_SYNOLOGY_PORT:-22} +user = ${REAL_SYNOLOGY_USER} +pass = $(rclone obscure "${REAL_SYNOLOGY_PASS}") +EOF +fi +exec uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/monitor/watch_batch.py b/monitor/watch_batch.py new file mode 100644 index 0000000..0ad1a60 --- /dev/null +++ b/monitor/watch_batch.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Submit a copy job to rclone's RC API and watch it live. +This is the telemetry prototype for the monitoring UI: every number printed +here comes from an HTTP endpoint the future FastAPI backend can poll. + +Usage: + python3 watch_batch.py [bwlimit] + python3 watch_batch.py /sources/batch_test synodrive:upload/BatchTest 3M + +Endpoints used: + POST /core/bwlimit throttle (demo only, so transfers are watchable) + POST /sync/copy submit job (_async=true -> jobid) + POST /job/status is the job finished? + POST /core/stats LIVE: totals, speed, ETA, in-flight files, queue + POST /core/transferred HISTORY: per-file started_at/completed_at/error +""" +import base64, json, re, sys, time, urllib.request +from datetime import datetime + +RC = "http://localhost:5572" +AUTH = "Basic " + base64.b64encode(b"admin:rc-admin-123").decode() + + +def rc(path, body=None): + req = urllib.request.Request( + RC + path, + data=json.dumps(body or {}).encode(), + headers={"Content-Type": "application/json", "Authorization": AUTH}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read()) + + +def parse_ts(s): + # rclone timestamps may carry nanoseconds; trim to microseconds + s = re.sub(r"(\.\d{6})\d+", r"\1", s) + return datetime.fromisoformat(s) + + +def fmt_mb(b): + return f"{b / 1e6:6.1f}MB" + + +def main(): + src, dst = sys.argv[1], sys.argv[2] + if len(sys.argv) > 3: + rc("/core/bwlimit", {"rate": sys.argv[3]}) + print(f"bwlimit set to {sys.argv[3]}/s (demo throttle)") + + job = rc("/sync/copy", {"srcFs": src, "dstFs": dst, "_async": True}) + jid = job["jobid"] + group = f"job/{jid}" + print(f"submitted jobid={jid} {src} -> {dst}\n") + + while True: + s = rc("/core/stats", {"group": group}) + done = s.get("transfers", 0) + total = s.get("totalTransfers", 0) + moving = s.get("transferring") or [] + queued = max(total - done - len(moving), 0) + eta = s.get("eta") + print( + f"scheduled:{total:3} done:{done:3} moving:{len(moving)} " + f"queued:{queued:3} {fmt_mb(s.get('bytes', 0))}/{fmt_mb(s.get('totalBytes', 0))} " + f"{s.get('speed', 0) / 1e6:5.2f}MB/s ETA:{'-' if eta is None else str(int(eta)) + 's'}" + ) + for t in moving: + print( + f" ↑ {t.get('name', '?'):32} {t.get('percentage', 0):3}% " + f"{t.get('speed', 0) / 1e6:5.2f}MB/s eta {t.get('eta', '-')}s" + ) + if rc("/job/status", {"jobid": jid}).get("finished"): + break + time.sleep(2) + + # ---- per-file history: this is what goes into MongoDB later ---- + hist = rc("/core/transferred", {"group": group}).get("transferred", []) + files = [h for h in hist if not h.get("checked")] # actual transfers, not skip-checks + print(f"\n=== per-file report ({len(files)} transferred) ===") + print(f"{'file':34} {'size':>8} {'seconds':>8} {'MB/s':>6} status") + for h in sorted(files, key=lambda h: h.get("started_at", "")): + try: + secs = (parse_ts(h["completed_at"]) - parse_ts(h["started_at"])).total_seconds() + except Exception: + secs = 0.0 + size = h.get("size", 0) + rate = size / 1e6 / secs if secs > 0 else 0 + status = "ERROR: " + h["error"] if h.get("error") else "ok" + print(f"{h.get('name', '?'):34} {fmt_mb(size)} {secs:8.2f} {rate:6.2f} {status}") + + rc("/core/bwlimit", {"rate": "off"}) + print("\nbwlimit removed; done.") + + +if __name__ == "__main__": + main() diff --git a/send-to-friend/agent-deploy.zip b/send-to-friend/agent-deploy.zip new file mode 100644 index 0000000..4ab04fb Binary files /dev/null and b/send-to-friend/agent-deploy.zip differ diff --git a/send-to-friend/agent-deploy/.env b/send-to-friend/agent-deploy/.env new file mode 100644 index 0000000..ac01e4e --- /dev/null +++ b/send-to-friend/agent-deploy/.env @@ -0,0 +1,19 @@ +# Copy to .env and fill in. Values below match JAGAN's test machine — +# adjust PRIMARY/SECONDARY paths for other machines (see their generated +# docker-compose.yml header comments). + +# identity shown on the dashboard — must be unique per machine +MACHINE_ID=JAGAN-TEST-01 +SITE=TEST + +# where the monitoring collector is reachable FROM THIS MACHINE +# (Tailscale IP of the collector's host, port 8000) +COLLECTOR_URL=http://100.x.y.z:8000 + +# shared secret — must exactly match AGENT_TOKEN on the collector +AGENT_TOKEN=change-me + +# paths from this machine's existing docker-compose.yml (rclone-synology-sync volumes) +SYNC_LOGS_DIR=/home/testing/JAGAN/Prerequisites/Sync_logs +IMAGE_DIR=/home/testing/JAGAN/Prerequisites/TAKELEAP/image_root_dir +CSV_DIR=/home/testing/JAGAN/Prerequisites/csv_files diff --git a/send-to-friend/agent-deploy/README.md b/send-to-friend/agent-deploy/README.md new file mode 100644 index 0000000..f53be22 --- /dev/null +++ b/send-to-friend/agent-deploy/README.md @@ -0,0 +1,27 @@ +# Monitoring agent — setup (5 minutes) + +This adds ONE container that reports sync status to the central dashboard. +It does NOT touch your existing containers. It only READS two folders. + +## 1. Install Tailscale (creates the network path to the dashboard) +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up # log in via the link (account details come separately) +``` + +## 2. Edit `.env` — only 2 values need changing +- `COLLECTOR_URL` → I'll send you the IP (looks like http://100.x.y.z:8000) +- `AGENT_TOKEN` → I'll send you the token separately + +The three folder paths are already set for your machine — if your setup +differs, they must match the host paths in your rclone-synology-sync volumes. + +## 3. Start it +```bash +docker compose up -d --build +docker logs rclone-agent +``` +Expected log line: `[agent] JAGAN-TEST-01 (TEST) -> http://100...:8000` +If it says "collector unreachable", check `tailscale status` and tell me. + +That's all. Nothing else on your machine changes. diff --git a/send-to-friend/agent-deploy/agent/Dockerfile b/send-to-friend/agent-deploy/agent/Dockerfile new file mode 100644 index 0000000..cce9a50 --- /dev/null +++ b/send-to-friend/agent-deploy/agent/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-alpine +COPY agent.py /agent.py +CMD ["python", "-u", "/agent.py"] diff --git a/send-to-friend/agent-deploy/agent/agent.py b/send-to-friend/agent-deploy/agent/agent.py new file mode 100644 index 0000000..a9792cc --- /dev/null +++ b/send-to-friend/agent-deploy/agent/agent.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Per-machine monitoring agent (sidecar container). + +Tails the rclone sync container's /logs/sync.log (mounted read-only), parses it +into structured events, and POSTs them to the central collector every few +seconds. The POST itself doubles as the machine's heartbeat — an empty event +list still proves the machine (and this agent) is alive. + +Works against the byte-identical production sync.sh: no changes to the sync +container are required. Python stdlib only — no pip installs. + +Env: + MACHINE_ID unique id for this machine (e.g. GCBOT-01) + SITE site/org label (e.g. GCBOT) + COLLECTOR_URL e.g. http://collector:8000 + SYNC_LOG default /logs/sync.log + POLL_SECONDS default 5 +""" +import json +import os +import re +import time +import urllib.request + +MACHINE_ID = os.environ.get("MACHINE_ID", "unknown") +SITE = os.environ.get("SITE", "unknown") +COLLECTOR = os.environ.get("COLLECTOR_URL", "http://collector:8000").rstrip("/") +SYNC_LOG = os.environ.get("SYNC_LOG", "/logs/sync.log") +POLL = float(os.environ.get("POLL_SECONDS", "5")) +AGENT_TOKEN = os.environ.get("AGENT_TOKEN", "") # must match the collector's +# Source dirs of the UP pairs, mounted ro at the SAME paths the sync container +# uses (e.g. /sources/csv_files) so inventory keys match pair labels. The +# collector diffs this against the server listing => pending/missing files. +WATCH_DIRS = [d for d in os.environ.get("WATCH_DIRS", "").split(",") if d] +MAX_INV_FILES = 2000 # safety cap per dir + + +def take_inventory(): + inv = {} + for d in WATCH_DIRS: + files = [] + try: + for root, _, names in os.walk(d): + for n in names: + p = os.path.join(root, n) + try: + st = os.stat(p) + except OSError: + continue + files.append({"name": os.path.relpath(p, d).replace("\\", "/"), + "size": st.st_size, "mtime": st.st_mtime}) + if len(files) >= MAX_INV_FILES: + break + except OSError: + pass + inv[d] = files + return inv + +# --- line patterns for sync.sh's log format --------------------------------- +RE_ROUND_START = re.compile(r"^=== Round #(\d+) started at (.+) ===") +RE_ROUND_DONE = re.compile(r"^=== Round #(\d+) done at (.+) ===") +RE_PAIR_START = re.compile(r"^--- \[(\S+ (?:UP|DOWN))\] (.+?) ---$") +RE_PAIR_OK = re.compile(r"^OK: \[(\S+ (?:UP|DOWN))\] (.+)$") +RE_PAIR_FAIL = re.compile(r"^ERROR: \[(\S+ (?:UP|DOWN))\] (.+) failed \(exit (\d+)\)$") +RE_COPIED = re.compile(r"^([\d/]+ [\d:]+) INFO\s+: (.+?): ((?:Copied|Moved).*)$") +RE_RCLONE_ERR = re.compile(r"^([\d/]+ [\d:]+) ERROR\s*: (.*)$") +# one-line stats: "2026/08/05 10:48:22 INFO : 8.5 MiB / 157 MiB, 5%, 4.2 MiB/s, ETA 32s" +RE_STATS = re.compile( + r"^([\d/]+ [\d:]+) INFO\s+:\s+(\S+ \S*B) / (\S+ \S*B), (\d+)%, (\S+ ?\S*B/s), ETA (\S+)" +) + + +class LogParser: + """Stateful line parser: remembers current round/pair for event context.""" + + def __init__(self): + self.round = None + self.pair = None # current pair label + self.direction = None # "up" | "down" + + def _dir(self, arrow): + return "up" if "UP" in arrow else "down" + + def feed(self, line): + """Return an event dict for this line, or None if it's noise.""" + line = line.rstrip("\r\n") + ctx = {"round": self.round, "pair": self.pair, "direction": self.direction} + + m = RE_ROUND_START.match(line) + if m: + self.round = int(m.group(1)) + self.pair = self.direction = None + return {"type": "round_start", "round": self.round, "log_ts": m.group(2)} + + m = RE_ROUND_DONE.match(line) + if m: + self.pair = self.direction = None + return {"type": "round_end", "round": int(m.group(1)), "log_ts": m.group(2)} + + m = RE_PAIR_START.match(line) + if m: + self.direction = self._dir(m.group(1)) + self.pair = m.group(2) + return {"type": "pair_start", "round": self.round, + "pair": self.pair, "direction": self.direction} + + m = RE_PAIR_OK.match(line) + if m: + return {"type": "pair_ok", "round": self.round, + "pair": m.group(2), "direction": self._dir(m.group(1))} + + m = RE_PAIR_FAIL.match(line) + if m: + return {"type": "pair_fail", "round": self.round, "pair": m.group(2), + "direction": self._dir(m.group(1)), "exit_code": int(m.group(3))} + + m = RE_COPIED.match(line) + if m: + return {"type": "file_synced", "log_ts": m.group(1), "file": m.group(2), + "action": m.group(3), **ctx} + + m = RE_STATS.match(line) + if m: + return {"type": "progress", "log_ts": m.group(1), "done": m.group(2), + "total": m.group(3), "percent": int(m.group(4)), + "speed": m.group(5), "eta": m.group(6), **ctx} + + m = RE_RCLONE_ERR.match(line) + if m: + return {"type": "error", "log_ts": m.group(1), "message": m.group(2), **ctx} + + return None + + +def post_events(events): + payload = { + "machine_id": MACHINE_ID, + "site": SITE, + "sent_at": time.time(), + "events": events, + } + if WATCH_DIRS: + payload["inventory"] = take_inventory() + headers = {"Content-Type": "application/json"} + if AGENT_TOKEN: + headers["Authorization"] = f"Bearer {AGENT_TOKEN}" + req = urllib.request.Request( + COLLECTOR + "/api/ingest", + data=json.dumps(payload).encode(), + headers=headers, + ) + urllib.request.urlopen(req, timeout=10).read() + + +def main(): + parser = LogParser() + f = None + offset = 0 + print(f"[agent] {MACHINE_ID} ({SITE}) -> {COLLECTOR}, log={SYNC_LOG}", flush=True) + + while True: + events = [] + try: + if f is None: + if os.path.exists(SYNC_LOG): + f = open(SYNC_LOG, "r", errors="replace") + f.seek(0, os.SEEK_END) # start from "now"; no historical replay + offset = f.tell() + else: + # handle rotation/truncation: file shrank -> reopen from start + size = os.path.getsize(SYNC_LOG) + if size < offset: + f.close() + f = open(SYNC_LOG, "r", errors="replace") + offset = 0 + for line in f: + ev = parser.feed(line) + if ev: + events.append(ev) + offset = f.tell() + except OSError as e: + print(f"[agent] log read error: {e}", flush=True) + f = None + + try: + post_events(events) # empty list == pure heartbeat + except Exception as e: + print(f"[agent] collector unreachable: {e}", flush=True) + + time.sleep(POLL) + + +if __name__ == "__main__": + main() diff --git a/send-to-friend/agent-deploy/docker-compose.yml b/send-to-friend/agent-deploy/docker-compose.yml new file mode 100644 index 0000000..2b730fc --- /dev/null +++ b/send-to-friend/agent-deploy/docker-compose.yml @@ -0,0 +1,30 @@ +# ============================================================================= +# AGENT-ONLY BUNDLE — for a machine that ALREADY runs the production +# rclone-synology-sync container (e.g. JAGAN's test machine). +# Adds monitoring only; touches nothing in the existing stack. +# +# 1. Copy this folder + the repo's monitor/agent/ folder to the machine: +# agent-deploy/ +# ├── docker-compose.yml (this file) +# ├── .env (from .env.example) +# └── agent/ (= monitor/agent/: agent.py + Dockerfile) +# 2. Fill .env +# 3. docker compose up -d --build +# ============================================================================= +services: + rclone-agent: + build: ./agent + container_name: rclone-agent + environment: + - MACHINE_ID=${MACHINE_ID} + - SITE=${SITE} + - COLLECTOR_URL=${COLLECTOR_URL} + - AGENT_TOKEN=${AGENT_TOKEN} + - WATCH_DIRS=/sources/image_root_dir,/sources/csv_files + volumes: + # the EXISTING sync container's log folder (read-only) + - ${SYNC_LOGS_DIR}:/logs:ro + # the up-pair source folders (read-only) — enables pending/missing detection + - ${IMAGE_DIR}:/sources/image_root_dir:ro + - ${CSV_DIR}:/sources/csv_files:ro + restart: unless-stopped diff --git a/sync-container/Dockerfile b/sync-container/Dockerfile new file mode 100644 index 0000000..245d26e --- /dev/null +++ b/sync-container/Dockerfile @@ -0,0 +1,8 @@ +FROM rclone/rclone:latest + +RUN apk add --no-cache openssh-client + +COPY sync.sh /sync.sh +RUN chmod +x /sync.sh + +ENTRYPOINT ["/bin/sh", "/sync.sh"] diff --git a/sync-container/sync.sh b/sync-container/sync.sh new file mode 100644 index 0000000..0a87cd6 --- /dev/null +++ b/sync-container/sync.sh @@ -0,0 +1,128 @@ +#!/bin/sh +mkdir -p /root/.config/rclone /logs +# Write rclone config +cat > /root/.config/rclone/rclone.conf << EOF +[synodrive] +type = sftp +host = ${SYNOLOGY_HOST} +port = ${SYNOLOGY_PORT:-22} +user = ${SYNOLOGY_USER} +pass = $(rclone obscure "${SYNOLOGY_PASS}") +EOF + +MAIN_LOG=/logs/sync.log +STATS_LOG=/logs/stats.log +ERROR_LOG=/logs/errors.log +COMPLETED_LOG=/logs/completed.log + +echo "=== Container started at $(date) ===" >> "$MAIN_LOG" + +# Background log splitter — reads MAIN_LOG live and routes lines to other logs +tail -n 0 -F "$MAIN_LOG" | while IFS= read -r line; do + if echo "$line" | grep -q " ERROR "; then + echo "$line" >> "$ERROR_LOG" + fi + if echo "$line" | grep -qE ": (Copied|Moved) "; then + echo "$line" >> "$COMPLETED_LOG" + fi +done & +FILTER_PID=$! + +# Keep syncing forever +ROUND=0 + +rclone rcd \ + --config /root/.config/rclone/rclone.conf \ + --rc-web-gui \ + --rc-addr 0.0.0.0:5572 \ + --rc-user admin \ + --rc-pass "$RC_PASSWORD" \ + --log-file /logs/rc.log & +RC_PID=$! + +echo "=== RC server started (PID $RC_PID) on port 5572 ===" >> "$MAIN_LOG" + + +while true; do + ROUND=$((ROUND + 1)) + ROUND_START=$(date '+%Y/%m/%d %H:%M:%S') + echo "" >> "$STATS_LOG" + echo "┌─ Round #${ROUND} — ${ROUND_START} ───────────────────────" >> "$STATS_LOG" + echo "=== Round #${ROUND} started at ${ROUND_START} ===" >> "$MAIN_LOG" + + i=1 + while true; do + eval "pair=\$SYNC_${i}" + [ -z "$pair" ] && break + i=$((i + 1)) + + direction=$(echo "$pair" | cut -d: -f1) + local_path=$(echo "$pair" | cut -d: -f2) + remote_path=$(echo "$pair" | cut -d: -f3-) + + if [ "$direction" = "down" ]; then + SRC="synodrive:${remote_path}" + DST="$local_path" + ARROW="↓ DOWN" + LABEL="server:${remote_path} → ${local_path}" + else + SRC="$local_path" + DST="synodrive:${remote_path}" + ARROW="↑ UP" + LABEL="${local_path} → server:${remote_path}" + fi + + echo "│ [${ARROW}] ${LABEL}" >> "$STATS_LOG" + echo "--- [${ARROW}] ${LABEL} ---" >> "$MAIN_LOG" + + # rclone writes its live log here; we tail it into MAIN_LOG in real time + PAIR_LOG=$(mktemp) + touch "$PAIR_LOG" + + # Stream rclone's output into MAIN_LOG as it's written (every line, live) + tail -n 0 -F "$PAIR_LOG" >> "$MAIN_LOG" & + TAIL_PID=$! + + rclone copy "$SRC" "$DST" \ + --size-only \ + --stats 5s \ + --stats-one-line \ + --stats-log-level INFO \ + --log-level INFO \ + --log-file "$PAIR_LOG" \ + --contimeout 15s \ + --timeout 30s \ + --low-level-retries 5 \ + --retries 3 + EXIT_CODE=$? + + # Give the tail a moment to flush the final lines, then stop it + sleep 1 + kill "$TAIL_PID" 2>/dev/null + + # Extract and format stats lines into stats log + grep -E ", [0-9]+%,.*/s, ETA" "$PAIR_LOG" | while IFS= read -r sline; do + pct_done=$(echo "$sline" | grep -oE ', [0-9]+%,' | tr -d ', %') + pct_remaining=$((100 - ${pct_done:-0})) + speed=$(echo "$sline" | grep -oE '[0-9.]+ [KMGTi]*B/s') + eta=$(echo "$sline" | grep -oE 'ETA [^ ]+') + ts=$(echo "$sline" | grep -oE '^[0-9/]+ [0-9:]+') + echo "│ ${ts} ${pct_done}% done, ${pct_remaining}% remaining | ${speed} | ${eta}" >> "$STATS_LOG" + done + + rm -f "$PAIR_LOG" + + if [ $EXIT_CODE -ne 0 ]; then + echo "│ ✗ FAILED (exit $EXIT_CODE)" >> "$STATS_LOG" + echo "ERROR: [$ARROW] $LABEL failed (exit $EXIT_CODE)" | tee -a "$MAIN_LOG" "$ERROR_LOG" + else + echo "│ ✓ done" >> "$STATS_LOG" + echo "OK: [$ARROW] $LABEL" >> "$MAIN_LOG" + fi + done + + ROUND_END=$(date '+%Y/%m/%d %H:%M:%S') + echo "└─ Round #${ROUND} done at ${ROUND_END} — next in ${SYNC_INTERVAL:-300}s" >> "$STATS_LOG" + echo "=== Round #${ROUND} done at ${ROUND_END} ===" >> "$MAIN_LOG" + sleep "${SYNC_INTERVAL:-300}" +done