initial commit: rclone sync stack with monitoring UI
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"PowerShell(node --version 2>&1)",
|
||||
"PowerShell(npm --version 2>&1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
.env
Normal file
15
.env
Normal 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
23
.env.example
Normal 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
69
.gitignore
vendored
Normal 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
193
CLAUDE.md
Normal 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
122
SETUP.md
Normal 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.
|
||||
3
client/GCBOT/master_sheet_GCBOT.csv
Normal file
3
client/GCBOT/master_sheet_GCBOT.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
chainage,lat,lon
|
||||
1.0,24.7,46.6
|
||||
2.0,24.8,46.7
|
||||
|
BIN
client/NIGHT/night_model.pt
Normal file
BIN
client/NIGHT/night_model.pt
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_01.mp4
Normal file
BIN
client/batch_test/video_chunk_01.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_02.mp4
Normal file
BIN
client/batch_test/video_chunk_02.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_03.mp4
Normal file
BIN
client/batch_test/video_chunk_03.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_04.mp4
Normal file
BIN
client/batch_test/video_chunk_04.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_05.mp4
Normal file
BIN
client/batch_test/video_chunk_05.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_06.mp4
Normal file
BIN
client/batch_test/video_chunk_06.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_07.mp4
Normal file
BIN
client/batch_test/video_chunk_07.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_08.mp4
Normal file
BIN
client/batch_test/video_chunk_08.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_09.mp4
Normal file
BIN
client/batch_test/video_chunk_09.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_10.mp4
Normal file
BIN
client/batch_test/video_chunk_10.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_11.mp4
Normal file
BIN
client/batch_test/video_chunk_11.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_12.mp4
Normal file
BIN
client/batch_test/video_chunk_12.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_13.mp4
Normal file
BIN
client/batch_test/video_chunk_13.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_14.mp4
Normal file
BIN
client/batch_test/video_chunk_14.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_15.mp4
Normal file
BIN
client/batch_test/video_chunk_15.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_16.mp4
Normal file
BIN
client/batch_test/video_chunk_16.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_17.mp4
Normal file
BIN
client/batch_test/video_chunk_17.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_18.mp4
Normal file
BIN
client/batch_test/video_chunk_18.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_19.mp4
Normal file
BIN
client/batch_test/video_chunk_19.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_20.mp4
Normal file
BIN
client/batch_test/video_chunk_20.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_21.mp4
Normal file
BIN
client/batch_test/video_chunk_21.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_22.mp4
Normal file
BIN
client/batch_test/video_chunk_22.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_23.mp4
Normal file
BIN
client/batch_test/video_chunk_23.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_24.mp4
Normal file
BIN
client/batch_test/video_chunk_24.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_25.mp4
Normal file
BIN
client/batch_test/video_chunk_25.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_26.mp4
Normal file
BIN
client/batch_test/video_chunk_26.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_27.mp4
Normal file
BIN
client/batch_test/video_chunk_27.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_28.mp4
Normal file
BIN
client/batch_test/video_chunk_28.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_29.mp4
Normal file
BIN
client/batch_test/video_chunk_29.mp4
Normal file
Binary file not shown.
BIN
client/batch_test/video_chunk_30.mp4
Normal file
BIN
client/batch_test/video_chunk_30.mp4
Normal file
Binary file not shown.
BIN
client/yolov8/yolov8n.pt
Normal file
BIN
client/yolov8/yolov8n.pt
Normal file
Binary file not shown.
19
deploy/agent-only/.env.example
Normal file
19
deploy/agent-only/.env.example
Normal 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
|
||||
30
deploy/agent-only/docker-compose.yml
Normal file
30
deploy/agent-only/docker-compose.yml
Normal 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
|
||||
23
deploy/remote-machine/.env.example
Normal file
23
deploy/remote-machine/.env.example
Normal 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
|
||||
43
deploy/remote-machine/docker-compose.yml
Normal file
43
deploy/remote-machine/docker-compose.yml
Normal 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
130
docker-compose.sim.yml
Normal 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
50
docker-compose.yml
Normal 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
12
frontend/index.html
Normal 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
1857
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal 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
260
frontend/src/App.jsx
Normal 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
6
frontend/src/main.jsx
Normal 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
95
frontend/src/styles.css
Normal 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
10
frontend/vite.config.js
Normal 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
29
generator/generate.sh
Normal 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, 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
|
||||
20
lan-forward.ps1
Normal file
20
lan-forward.ps1
Normal 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
3
monitor/agent/Dockerfile
Normal 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
195
monitor/agent/agent.py
Normal 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()
|
||||
8
monitor/collector/Dockerfile
Normal file
8
monitor/collector/Dockerfile
Normal 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
341
monitor/collector/main.py
Normal 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")
|
||||
28
monitor/collector/start.sh
Normal file
28
monitor/collector/start.sh
Normal 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
97
monitor/watch_batch.py
Normal 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()
|
||||
BIN
send-to-friend/agent-deploy.zip
Normal file
BIN
send-to-friend/agent-deploy.zip
Normal file
Binary file not shown.
19
send-to-friend/agent-deploy/.env
Normal file
19
send-to-friend/agent-deploy/.env
Normal 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
|
||||
27
send-to-friend/agent-deploy/README.md
Normal file
27
send-to-friend/agent-deploy/README.md
Normal 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.
|
||||
3
send-to-friend/agent-deploy/agent/Dockerfile
Normal file
3
send-to-friend/agent-deploy/agent/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM python:3.12-alpine
|
||||
COPY agent.py /agent.py
|
||||
CMD ["python", "-u", "/agent.py"]
|
||||
195
send-to-friend/agent-deploy/agent/agent.py
Normal file
195
send-to-friend/agent-deploy/agent/agent.py
Normal 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()
|
||||
30
send-to-friend/agent-deploy/docker-compose.yml
Normal file
30
send-to-friend/agent-deploy/docker-compose.yml
Normal 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
|
||||
8
sync-container/Dockerfile
Normal file
8
sync-container/Dockerfile
Normal 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
128
sync-container/sync.sh
Normal 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
|
||||
Reference in New Issue
Block a user