initial commit: rclone sync stack with monitoring UI

This commit is contained in:
seekr
2026-08-07 09:36:35 +00:00
commit 11c1a29b60
68 changed files with 4118 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"PowerShell(node --version 2>&1)",
"PowerShell(npm --version 2>&1)"
]
}
}

15
.env Normal file
View File

@@ -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

23
.env.example Normal file
View File

@@ -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

69
.gitignore vendored Normal file
View File

@@ -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

193
CLAUDE.md Normal file
View File

@@ -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/<ORG>/<SITE>/TEST` |
| 2 | down | master sheets ← `ClientSync/ALGORITHM_DEPLOYMENT/Master_Sheets/<ORG>/<SITE>` |
| 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/<ORG>/<SITE>` |
**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 <name>`.
## 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/<jobid>"}` 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 <token>` (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 `<ORG>/<SITE>`.
- `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.

122
SETUP.md Normal file
View File

@@ -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=<paste the random value>
```
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://<IP from step A2>: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://<ip>: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.

View File

@@ -0,0 +1,3 @@
chainage,lat,lon
1.0,24.7,46.6
2.0,24.8,46.7
1 chainage lat lon
2 1.0 24.7 46.6
3 2.0 24.8 46.7

BIN
client/NIGHT/night_model.pt Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
client/yolov8/yolov8n.pt Normal file

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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://<your-lan-ip>: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

View File

@@ -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

130
docker-compose.sim.yml Normal file
View File

@@ -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

50
docker-compose.yml Normal file
View File

@@ -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:

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rclone Fleet Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1857
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -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"
}
}

260
frontend/src/App.jsx Normal file
View File

@@ -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 (
<span className="badge" style={{ color: s.color }}>
<span aria-hidden="true">{s.icon}</span>
<span className="badge-text">{text ?? s.label}</span>
</span>
);
}
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 (
<div className="pair-row">
<span className="pair-dir">{arrow} {p.direction}</span>
<span className="pair-label" title={p.pair}>{label}</span>
<span>
{p.pending_count > 0 && (
<span className="pending-chip mono" title="on disk, not yet on server">
{p.pending_count} pending{p.pending_bytes > 0 && ` · ${fmtBytes(p.pending_bytes)}`}
</span>
)}
{p.missing?.length > 0 && (
<span className="missing-chip mono" title={p.missing.join("\n")}>
{p.missing.length} missing!
</span>
)}
<StatusBadge
kind={p.missing?.length ? "serious" : kind}
text={p.last_status === "fail" ? `exit ${p.last_exit}` : p.last_status}
/>
</span>
</div>
);
}
function MachineCard({ m, selected, onSelect }) {
const kind = m.online ? "good" : "critical";
return (
<div className={"card" + (selected ? " card-selected" : "")} onClick={() => onSelect(m.machine_id)}>
<div className="card-head">
<div>
<div className="card-title">{m.machine_id}</div>
<div className="card-sub">site {m.site} · seen {ago(m.last_seen)}</div>
</div>
<StatusBadge kind={kind} />
</div>
{!m.progress && m.last_transfer && (
<div className="progress-line">
last transfer · <span className="mono">{m.last_transfer.files}</span> files ·{" "}
<span className="mono">{fmtBytes(m.last_transfer.bytes)}</span> in{" "}
<span className="mono">{fmtDur(m.last_transfer.duration_s).replace("~", "")}</span>
{m.last_transfer.avg_bps != null && (
<> · <span className="mono">{fmtBytes(m.last_transfer.avg_bps)}/s</span></>
)}
</div>
)}
{m.progress && (
<div className="progress-line">
<span className="mono">{m.progress.percent}%</span> · {m.progress.speed} · ETA{" "}
{m.progress.eta} <span className="card-sub">({m.progress.direction} sync)</span>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${m.progress.percent}%` }} />
</div>
</div>
)}
<div className="tiles">
<div className="tile">
<div className="tile-num mono">{m.last_round ?? ""}</div>
<div className="tile-label">last round · {ago(m.last_round_at)}</div>
</div>
<div className="tile">
<div className="tile-num mono">{m.files_1h}</div>
<div className="tile-label">files synced (1h)</div>
</div>
<div className="tile">
<div className="tile-num mono" style={m.pending_total > 0 ? { color: STATUS.warning.color } : {}}>
{m.pending_total ?? ""}
</div>
<div className="tile-label">
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)}`}
</div>
</div>
<div className="tile">
<div className="tile-num mono" style={m.errors_1h ? { color: STATUS.serious.color } : {}}>
{m.errors_1h}
</div>
<div className="tile-label">errors (1h)</div>
</div>
</div>
<div className="pairs">{m.pairs.map((p) => <PairRow key={p.pair} p={p} />)}</div>
</div>
);
}
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 (
<div className="panel">
<div className="panel-head">
<h2>{machineId} recent activity</h2>
<div className="tabs">
{TABS.map(([k, label]) => (
<button key={k} className={tab === k ? "tab tab-on" : "tab"} onClick={() => setTab(k)}>
{label}
</button>
))}
</div>
</div>
<table className="events">
<thead>
<tr><th>when</th><th>type</th><th>detail</th></tr>
</thead>
<tbody>
{events.map((e, i) => (
<tr key={i}>
<td className="mono">{ago(e.received_at)}</td>
<td>{e.type}</td>
<td className="detail">
{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"}`}
</td>
</tr>
))}
{events.length === 0 && (
<tr><td colSpan="3" className="empty">no events yet waiting for the next sync round</td></tr>
)}
</tbody>
</table>
</div>
);
}
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 (
<div className="app">
<header>
<h1>Rclone Fleet Monitor</h1>
<div className="header-stats mono">
{online}/{machines.length} machines online
</div>
</header>
{err && <div className="alert alert-critical"><span aria-hidden="true"></span> collector unreachable: {err}</div>}
{alerts.map((a, i) => (
<div key={i} className={`alert alert-${a.severity}`}>
<span aria-hidden="true">{STATUS[a.severity]?.icon ?? "▲"}</span>
<strong>{a.severity.toUpperCase()}</strong> · {a.machine_id} {a.message}
</div>
))}
<div className="grid">
{machines.map((m) => (
<MachineCard key={m.machine_id} m={m} selected={selected === m.machine_id} onSelect={setSelected} />
))}
{machines.length === 0 && !err && <div className="empty">no machines reporting yet</div>}
</div>
<EventsPanel machineId={selected} />
</div>
);
}

6
frontend/src/main.jsx Normal file
View File

@@ -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(<App />);

95
frontend/src/styles.css Normal file
View File

@@ -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; }

10
frontend/vite.config.js Normal file
View File

@@ -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" },
},
});

29
generator/generate.sh Normal file
View File

@@ -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, 100500 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

20
lan-forward.ps1 Normal file
View File

@@ -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."

3
monitor/agent/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM python:3.12-alpine
COPY agent.py /agent.py
CMD ["python", "-u", "/agent.py"]

195
monitor/agent/agent.py Normal file
View File

@@ -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()

View File

@@ -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"]

341
monitor/collector/main.py Normal file
View File

@@ -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")

View File

@@ -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

97
monitor/watch_batch.py Normal file
View File

@@ -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 <srcFs> <dstFs> [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()

Binary file not shown.

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
FROM python:3.12-alpine
COPY agent.py /agent.py
CMD ["python", "-u", "/agent.py"]

View File

@@ -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()

View File

@@ -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

View File

@@ -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"]

128
sync-container/sync.sh Normal file
View File

@@ -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