From 7cb85d5ebce5c91bd33a19bc01af03ed1b2f217a Mon Sep 17 00:00:00 2001 From: ravi Date: Tue, 16 Jun 2026 14:32:21 +0530 Subject: [PATCH] Central Verification Dashboard --- .gitignore | 4 + README.md | 176 ++ dashboard/.dockerignore | 3 + dashboard/Dockerfile | 11 + dashboard/index.html | 12 + dashboard/nginx.conf | 18 + dashboard/package-lock.json | 1888 +++++++++++++ dashboard/package.json | 22 + dashboard/src/AdminPanel.tsx | 460 ++++ dashboard/src/App.tsx | 568 ++++ dashboard/src/FoldersPicker.tsx | 78 + dashboard/src/MembersPicker.tsx | 88 + dashboard/src/RemarkCell.tsx | 110 + dashboard/src/SignIn.tsx | 56 + dashboard/src/api.ts | 308 +++ dashboard/src/main.tsx | 10 + dashboard/src/styles.css | 254 ++ dashboard/src/vite-env.d.ts | 1 + dashboard/tsconfig.json | 20 + dashboard/tsconfig.tsbuildinfo | 1 + dashboard/vite.config.ts | 12 + db/init.sql | 224 ++ docker-compose.yml | 98 + docs/er-diagram.md | 169 ++ sample-data/2026_0508_092422_F.MP4 | 0 .../2026_0508_092422_F.MP4_annotations.json | 33 + sample-data/2026_0508_101500_R.MP4 | 0 server/Cargo.lock | 2377 +++++++++++++++++ server/Cargo.toml | 22 + server/Dockerfile | 18 + server/src/admin.rs | 435 +++ server/src/auth.rs | 93 + server/src/ingest.rs | 254 ++ server/src/main.rs | 969 +++++++ server/src/models.rs | 426 +++ server/src/nfs.rs | 146 + server/src/worker.rs | 301 +++ 37 files changed, 9665 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dashboard/.dockerignore create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/index.html create mode 100644 dashboard/nginx.conf create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/src/AdminPanel.tsx create mode 100644 dashboard/src/App.tsx create mode 100644 dashboard/src/FoldersPicker.tsx create mode 100644 dashboard/src/MembersPicker.tsx create mode 100644 dashboard/src/RemarkCell.tsx create mode 100644 dashboard/src/SignIn.tsx create mode 100644 dashboard/src/api.ts create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/src/styles.css create mode 100644 dashboard/src/vite-env.d.ts create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/tsconfig.tsbuildinfo create mode 100644 dashboard/vite.config.ts create mode 100644 db/init.sql create mode 100644 docker-compose.yml create mode 100644 docs/er-diagram.md create mode 100644 sample-data/2026_0508_092422_F.MP4 create mode 100644 sample-data/2026_0508_092422_F.MP4_annotations.json create mode 100644 sample-data/2026_0508_101500_R.MP4 create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/Dockerfile create mode 100644 server/src/admin.rs create mode 100644 server/src/auth.rs create mode 100644 server/src/ingest.rs create mode 100644 server/src/main.rs create mode 100644 server/src/models.rs create mode 100644 server/src/nfs.rs create mode 100644 server/src/worker.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f09d5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +dist/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..aca5be4 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# Central Verification Dashboard (Phase 1) + +Dockerized pipeline that ingests a folder of videos + their sibling export-JSONs into Postgres and +serves a manager dashboard: completion overview, per-user leaderboard, throughput + ETA, and a +per-video table. Standalone — it does **not** touch the desktop video-annotator app. + +> Phase 1 = the dashboard pipeline. Worker pull/push (claim a video, verify, push) + token auth are +> **Phase 3** and not built yet. So in this phase the dashboard shows the **annotation snapshot** +> ingested from the NAS JSONs: `pending` (no JSON) vs `annotated` (has a JSON) vs `verified` +> (a pushed pass — 0 until Phase 3). + +## Run it + +```bash +cd central +docker compose up --build +``` + +- Dashboard → http://localhost:8081 +- API → http://localhost:8080 (e.g. `curl localhost:8080/api/projects`) +- Postgres → localhost:5433 (user/pass/db = `central`) + +On startup the server creates a `default` project pointing at `INGEST_DIR` (`/data`, mounted from +`./sample-data`) and ingests it once. Click **Re-ingest** in the dashboard to re-scan after files +change. Re-ingest is idempotent and never downgrades a `verified` video. + +## Point it at the NAS (on the VM) + +The ingest folder is just a path/volume. On the VM, mount the Synology share and map it to `/data`: + +```yaml +# docker-compose.override.yml on the VM +services: + server: + volumes: + - /mnt/synology/:/data:ro +``` + +Then `POST /api/projects//ingest` (or restart the server). No code changes. + +## Database backups (→ NAS) + +A `db-backup` sidecar (`prodrigestivill/postgres-backup-local`) runs `pg_dump` on a schedule +and writes gzipped dumps straight to the NAS, pruning old ones automatically. + +- **Schedule:** daily at 02:00 IST (`SCHEDULE` + `TZ: Asia/Kolkata` in `docker-compose.yml`). +- **Destination:** the `nasbackups` NFS volume → `:/volume4/Saudi_Video_Sync/Verification_Dashboard_Backup` + on the NAS (`NFS_HOST`, default `192.168.1.199`). **Create that folder on the NAS** and allow + rw NFS from the Docker host; the host needs an NFS client (`nfs-common`). +- **Retention:** `BACKUP_KEEP_DAYS=30`, `BACKUP_KEEP_WEEKS=8`, `BACKUP_KEEP_MONTHS=6` (tune to taste). + Dumps land under `daily/`, `weekly/`, `monthly/` as `central-YYYYMMDD-HHMMSS.sql.gz`. + +```bash +docker compose up -d db-backup # start it +docker compose exec db-backup /backup.sh # run an immediate backup (verify it writes to the NAS) +docker compose logs db-backup # check the schedule/last run +``` + +**Restore** a dump into the running DB: + +```bash +gunzip -c /path/on/nas/daily/central-YYYYMMDD-HHMMSS.sql.gz \ + | docker compose exec -T db psql -U central -d central +``` + +(For a clean restore, drop+recreate the DB first, or restore into a scratch DB and compare.) +Test a restore periodically — an untested backup isn't a backup. + +## Layout + +``` +central/ + db/init.sql # Postgres schema (projects, videos, annotations) + server/ # Rust/Axum API + folder ingest (embeds db/init.sql) + dashboard/ # React + Vite, served by nginx (proxies /api → server) + sample-data/ # demo: one annotated video JSON + two empty .mp4 placeholders + docker-compose.yml +``` + +## Ingest details + +For each video file (`.mp4/.mkv/.avi/.mov/.m4v/.webm`) it looks for a sibling JSON named +`_annotations.json`, `.json`, or `.json` (video-annotator's export format — +`{ video, fixed_annotations[], range_annotations[] }`). From it the server derives per-video +annotation count, `annotated_by` (primary), `annotation_time_ms`, and `annotated_at` (latest +annotation timestamp, used for the throughput chart), and stores the full doc in `videos.raw_json` +for Phase 3 pull. Videos without a JSON are recorded as `pending`. + +## API + +### Clients → projects + +A **client** (e.g. *IRB*) owns one or more **projects**; each project points at a folder via +`source_path` + `source_kind`. Project names are unique **per client**. Use the dashboard's +**Admin** tab: sign in (token) → create client (admin) → create project → **Sync**. A `default` +client/project is seeded **only on a fresh DB** (local dev); once real clients exist it never reappears. + +**Sync from NFS (`source_kind='nfs'`).** A project's `source_path` may be a **bare export path** +(`/volume4/Saudi_Video_Sync` — the host comes from the `NFS_HOST` env, so the **IP stays server-side and +is never shown/returned**), or include the host explicitly as `host:/volume4/Share` or +`nfs://host/volume4/Share`. On sync the server mounts the share on demand (a fast TCP probe to port 2049 +fails an unreachable NAS in ~3 s; the mount is bounded so a stuck NAS can't hang the request). +`source_kind='local'` keeps the old behaviour (a path inside the container, incl. a compose-managed NFS +volume). Point a project at a **subfolder** (e.g. `/volume4/Saudi_Video_Sync/IRB_Master_VIdeos`), not a +huge share root. + +**Subfolders.** Ingest walks the folder **recursively**, so videos nested in subfolders are included; +each video's `rel_path` carries its subfolder, which the dashboard shows in a **Folder** column. + +> In-app NFS mounting needs `nfs-common` (in the image) and the server container's `cap_add: SYS_ADMIN` +> + `security_opt: apparmor:unconfined` (in `docker-compose.yml`; use `privileged: true` if your host's +> `mount.nfs` still refuses). `local`-only deployments need none of this. + +### Roles + +- **admin** (role `admin`, or the `ADMIN_TOKEN`): everything — create clients, manage collaborators, etc. +- **collaborator** (role `worker`): can **add a project** and **change a project's sync directory**, but + **not** create clients or manage users. + +### Dashboard read APIs (open — the dashboard is manager-only on the VM) + +- `GET /api/clients` — clients + project counts +- `GET /api/projects` — projects + counts (each carries `client_id`, `client_name`, `source_kind`) +- `GET /api/projects/:id/videos` — per-video rows (incl. claim/verify columns) +- `GET /api/projects/:id/stats` — overview, annotator leaderboard, `verifiers`, throughput, eta_days +- `GET /api/projects/:id/activity` — live claims + recent `video_events` +- `GET /api/audit?limit=N` — org audit log (client/project created, sync dir changed, synced, user created) +- `POST /api/projects/:id/ingest` — re-scan the project's folder ("Sync"); attributes the actor if a token is sent + +## Phase 3 — worker pull/push + token auth + +Workers claim an annotated video, verify it in the desktop app, and push the verified +pass back. All Phase 3 routes require `Authorization: Bearer `. + +- **Auth model:** tokens are random 256-bit hex strings; only their SHA-256 hash is + stored (`users.token_hash`). An admin provisions users and hands out the token (shown + **once**). A master `ADMIN_TOKEN` env var grants admin (bootstrap / break-glass). +- **Status vs. claim:** the annotation `status` (`pending`/`annotated`/`verified`) is + unchanged; claiming is a separate dimension (`claimed_by` + `lease_expires_at`). A video + is claimable when `annotated` and unclaimed-or-lease-expired. A push flips it to + `verified`, records `completed_by`/`verify_time_ms`, and clears the claim. Re-ingest then + **skips** verified videos (the pushed pass is authoritative). +- **Leases auto-release:** a 60 s background task returns claims whose lease lapsed + (`LEASE_SECS`, default 900) to the pool and logs an `auto_release` event. + +### Admin (require admin / `ADMIN_TOKEN`) + +- `POST /api/users` `{username, display_name?, role?}` → `{username, role, token}` (token shown once) +- `GET /api/users` — list users (no tokens) +- `POST /api/clients` `{name}` → the new client (unique name; 409 on dup) + +### Create / manage projects (require any valid token — workers included) + +- `POST /api/projects` `{client_id, name, source_path, source_kind}` — create/repoint a project + under a client (idempotent on `(client_id, name)`); `source_kind` = `local`|`nfs` +- `POST /api/projects/:id/source` `{source_path, source_kind}` — change a project's sync directory + +### Worker (require any valid token) + +- `GET /api/auth/whoami` — validate token → `{username, role}` +- `POST /api/videos/:id/claim` — atomic claim (annotated + unclaimed/expired); returns + metadata + `download_url` + preloaded `annotations` +- `GET /api/videos/:id/download` — stream the source bytes (claimant/admin only) +- `POST /api/videos/:id/heartbeat` — extend the lease while verifying +- `POST /api/videos/:id/release` — abandon the claim, back to the pool +- `POST /api/videos/:id/push` — body = export doc + `verify_time_ms`; replace annotations, + mark `verified`, record verifier + time, clear claim + +### Env + +| var | default | meaning | +|---------------|------------------------------------------|----------------------------------------------------| +| `ADMIN_TOKEN` | `dev-admin-token` | master admin token — **change in production** | +| `LEASE_SECS` | `900` | claim lease length; auto-released after this | +| `NFS_HOST` | _(empty)_ | NAS host/IP for `nfs`-kind projects (server-side only, never returned) | +| `NFS_OPTS` | `nfsvers=4,ro,soft,timeo=30,retrans=2,retry=0` | mount options for `nfs`-kind projects | diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..b431156 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.vite diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..6ddbd13 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..8a2bf4f --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + Verification Dashboard + + +
+ + + diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf new file mode 100644 index 0000000..34ab462 --- /dev/null +++ b/dashboard/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to the server container (same Docker network). + location /api/ { + proxy_pass http://server:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..8da55bf --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,1888 @@ +{ + "name": "central-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "central-dashboard", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..a974f26 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,22 @@ +{ + "name": "central-dashboard", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/dashboard/src/AdminPanel.tsx b/dashboard/src/AdminPanel.tsx new file mode 100644 index 0000000..92b0c99 --- /dev/null +++ b/dashboard/src/AdminPanel.tsx @@ -0,0 +1,460 @@ +import { useCallback, useEffect, useState } from "react"; +import { api, fmtBytes, type AuditRow, type Client, type Member, type ProjectSummary, type StorageInfo, type UserRow } from "./api"; + +/** Admin/collaborator console. The signed-in token (from the app gate) is used for + * everything — there's no separate login here. Role-aware: ml_support can add + * projects + change a project's sync directory; admins additionally create/delete + * clients, delete projects, and manage (create / reset-token / delete) collaborators. */ +export function AdminPanel({ role, onChanged }: { token: string; role: string; onChanged: () => void }) { + const isAdmin = role === "admin"; + + const [clients, setClients] = useState([]); + const [projects, setProjects] = useState([]); + const [users, setUsers] = useState([]); + const [audit, setAudit] = useState([]); + const [storage, setStorage] = useState(null); + + const [notice, setNotice] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); + const [busy, setBusy] = useState(false); + + // form state + const [clientName, setClientName] = useState(""); + const [projClientId, setProjClientId] = useState(""); + const [projName, setProjName] = useState(""); + const [projKind, setProjKind] = useState<"nfs" | "local">("nfs"); + const [projPath, setProjPath] = useState(""); + const [manageId, setManageId] = useState(""); + const [manageKind, setManageKind] = useState<"nfs" | "local">("nfs"); + const [managePath, setManagePath] = useState(""); + const [members, setMembers] = useState([]); + const [memberPick, setMemberPick] = useState(""); + const [newUsername, setNewUsername] = useState(""); + const [newDisplay, setNewDisplay] = useState(""); + const [newRole, setNewRole] = useState<"ml_support" | "admin">("ml_support"); + const [createdToken, setCreatedToken] = useState<{ username: string; token: string } | null>(null); + const [editing, setEditing] = useState<{ id: number; display_name: string; role: "ml_support" | "admin" } | null>(null); + + const refreshUsers = useCallback(async () => { + try { setUsers(await api.listUsers()); } catch { setUsers([]); } + try { setStorage(await api.storage()); } catch { setStorage(null); } + }, []); + + const refresh = useCallback(async () => { + try { + const [c, p, a] = await Promise.all([api.clients(), api.projects(), api.audit(50)]); + setClients(c); + setProjects(p); + setAudit(a); + } catch (e) { + setNotice({ kind: "err", msg: String(e) }); + } + }, []); + + useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { if (isAdmin) void refreshUsers(); }, [isAdmin, refreshUsers]); + + // When a project is picked in "Manage", load its current sync dir + members. + const loadMembers = useCallback(async (id: number) => { + try { setMembers(await api.members(id)); } catch { setMembers([]); } + }, []); + + useEffect(() => { + const p = projects.find((x) => x.id === manageId); + if (p) { + setManagePath(p.source_path); + setManageKind(p.source_kind === "nfs" ? "nfs" : "local"); + } + if (manageId !== "" && isAdmin) void loadMembers(Number(manageId)); + else setMembers([]); + }, [manageId, projects, isAdmin, loadMembers]); + + const run = async (fn: () => Promise, needAdmin = false) => { + if (needAdmin && !isAdmin) { setNotice({ kind: "err", msg: "admin only" }); return; } + setBusy(true); + setNotice(null); + try { + const msg = await fn(); + setNotice({ kind: "ok", msg }); + await refresh(); + if (isAdmin) void refreshUsers(); + onChanged(); + } catch (e) { + setNotice({ kind: "err", msg: String(e) }); + } finally { + setBusy(false); + } + }; + + const createClient = () => run(async () => { + const c = await api.createClient(clientName.trim()); + setClientName(""); + return `Created client '${c.name}'`; + }, true); + + const deleteClient = (c: Client) => { + // Re-affirmation: typing the exact client name confirms a destructive cascade. + const typed = window.prompt( + `Delete client "${c.name}" and ALL its ${c.project_count} project(s), videos and annotations?\n\nThis cannot be undone. Type the client name to confirm:`, + ); + if (typed == null) return; + if (typed.trim() !== c.name) { setNotice({ kind: "err", msg: "name didn't match — deletion cancelled" }); return; } + void run(async () => { + const r = await api.deleteClient(c.id); + return `Deleted client '${c.name}' (${r.projects_removed} project(s) removed)`; + }, true); + }; + + const createProject = () => run(async () => { + if (projClientId === "") throw new Error("pick a client"); + await api.createProject( + { client_id: Number(projClientId), name: projName.trim(), source_path: projPath.trim(), source_kind: projKind }, + ); + const msg = `Created project '${projName.trim()}'`; + setProjName(""); setProjPath(""); + return msg; + }); + + const saveSyncDir = () => run(async () => { + if (manageId === "") throw new Error("pick a project"); + await api.updateProjectSource(Number(manageId), { source_path: managePath.trim(), source_kind: manageKind }); + return `Sync directory updated`; + }); + + const syncProject = () => run(async () => { + if (manageId === "") throw new Error("pick a project"); + const r = await api.ingest(Number(manageId)); + return `Synced — ingested ${r.ingested} video(s)`; + }); + + const deleteProject = () => { + if (manageId === "") { setNotice({ kind: "err", msg: "pick a project" }); return; } + const p = projects.find((x) => x.id === manageId); + if (!p) return; + if (!window.confirm(`Delete project "${p.name}" and all its videos + annotations? This cannot be undone.`)) return; + void run(async () => { + await api.deleteProject(p.id); + setManageId(""); + return `Deleted project '${p.name}'`; + }, true); + }; + + const addMember = () => { + if (manageId === "" || !memberPick) return; + void run(async () => { + await api.addMember(Number(manageId), memberPick); + const u = memberPick; + setMemberPick(""); + await loadMembers(Number(manageId)); + return `Added '${u}' to the project`; + }, true); + }; + + const removeMember = (username: string) => { + if (manageId === "") return; + if (!confirm(`Remove "${username}" from this project? Their assignments here are cleared.`)) return; + void run(async () => { + await api.removeMember(Number(manageId), username); + await loadMembers(Number(manageId)); + return `Removed '${username}' from the project`; + }, true); + }; + + const addUser = () => run(async () => { + const u = await api.createUser({ username: newUsername.trim(), display_name: newDisplay.trim(), role: newRole }); + setCreatedToken({ username: u.username, token: u.token }); + setNewUsername(""); setNewDisplay(""); + return `Added ${u.role} '${u.username}'`; + }, true); + + const resetToken = (u: UserRow) => { + if (!confirm(`Generate a new access token for "${u.username}"? Their current token stops working immediately.`)) return; + void run(async () => { + const r = await api.resetUserToken(u.id); + setCreatedToken({ username: r.username, token: r.token }); + return `New token generated for '${r.username}'`; + }, true); + }; + + const saveEdit = () => { + if (!editing) return; + void run(async () => { + const r = await api.updateUser(editing.id, { display_name: editing.display_name.trim(), role: editing.role }); + setEditing(null); + return `Updated user → ${r.display_name} (${r.role})`; + }, true); + }; + + const deleteUser = (u: UserRow) => { + if (!confirm(`Delete ${u.role} "${u.username}"? Their token stops working immediately. Past work keeps their name.`)) return; + void run(async () => { + await api.deleteUser(u.id); + return `Deleted user '${u.username}'`; + }, true); + }; + + const pathHint = projKind === "nfs" + ? "/volume4/Saudi_Video_Sync (or nfs://host/volume4/Share)" + : "Container path (e.g. /data/Microsoft/India)"; + const manageHint = manageKind === "nfs" + ? "/volume4/Saudi_Video_Sync (or nfs://host/volume4/Share)" + : "Container path (e.g. /data/...)"; + + return ( +
+ {!isAdmin && ( +

+ ML support access: you can add projects and change a project's sync directory. + Creating/deleting clients, deleting projects, and managing collaborators is admin-only. +

+ )} + + {notice && ( +
+ {notice.kind === "ok" ? "✓ " : "⚠ "}{notice.msg} +
+ )} + +
+ {/* Create client — admin only */} + {isAdmin && ( +
+

Create client

+
+ + setClientName(e.target.value)} /> + +
+
+ )} + + {/* Create project — all */} +
+

Create project

+
+ + + + setProjName(e.target.value)} /> + + + + setProjPath(e.target.value)} /> + +
+
+
+ + {/* Clients list with delete — admin only */} + {isAdmin && ( +
+

Clients

+ + + + {clients.map((c) => ( + + + + + + + ))} + {clients.length === 0 && } + +
ClientProjectsCreated
{c.name}{c.project_count}{c.created_at.slice(0, 10)}
no clients yet
+
+ )} + + {/* Manage / sync a project — all (delete is admin only) */} +
+

Manage project — change sync directory & sync

+
+ + + {manageId !== "" && ( + <> + + + + setManagePath(e.target.value)} /> +
+ + + {isAdmin && } +
+ + )} +
+ + {/* Project members — admin picks who can see / be assigned this project */} + {manageId !== "" && isAdmin && ( +
+

Members — who can see & be assigned this project

+
+ + +
+
+ {members.map((m) => ( + + {m.username} {m.role} + + + ))} + {members.length === 0 && No members yet — this project is hidden from all ml_support users.} +
+
+ )} +

+ Sync re-scans the folder (incl. subfolders) for new/changed videos + JSONs. For NFS, enter the + export path (e.g. /volume4/Saudi_Video_Sync/IRB_Master_VIdeos) — a bare path uses + the NAS address configured on the server; a full nfs://host/path also works. +

+
+ + {/* Collaborators — admin only */} + {isAdmin && ( +
+

Collaborators

+ {createdToken && ( +
+
+ New token for {createdToken.username} — copy it now, it won't be shown again: +
+
+ {createdToken.token} + + +
+
+ )} +
+ setNewUsername(e.target.value)} /> + setNewDisplay(e.target.value)} /> + + +
+

+ Tokens are stored hashed and can't be displayed. Use Reset token to issue a new one + (shown once, here). +

+ + + + {users.map((u) => editing && editing.id === u.id ? ( + + + + + + + + ) : ( + + + + + + + + ))} + {users.length === 0 && } + +
UserRoleActiveSince
+ {u.username}{" "} + setEditing({ ...editing, display_name: e.target.value })} + style={{ width: 140 }} /> + + + {u.active ? "yes" : "no"}{u.created_at.slice(0, 10)} + + +
{u.username}{u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ""}{u.role}{u.active ? "yes" : "no"}{u.created_at.slice(0, 10)} + + + +
no collaborators yet
+
+ )} + + {/* Storage — admin only */} + {isAdmin && storage && ( +
+

Storage

+
+
Database size{fmtBytes(storage.db_bytes)}
+
Videos{storage.videos}
+
Annotations{storage.annotations}
+
Event log rows{storage.video_events}
+
Audit log rows{storage.audit_events}
+
+

+ The activity + audit logs are trimmed to the most recent {storage.max_log_rows} rows nightly + (~00:00 UTC) so the database stays bounded. Verification (push) history is kept. +

+
+ )} + + {/* Activity log — all */} +
+

Activity log

+ + + + {audit.map((a, i) => ( + + + + + + + ))} + {audit.length === 0 && } + +
TimeUserActionDetails
{a.at.slice(0, 19).replace("T", " ")}{a.username || "—"}{a.action}{auditDetail(a)}
no activity yet
+
+
+ ); +} + +function auditDetail(a: AuditRow): string { + const d = a.detail || {}; + const parts: string[] = []; + for (const [k, v] of Object.entries(d)) parts.push(`${k}=${v}`); + return parts.join(" "); +} diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 0000000..7e2a5db --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,568 @@ +import { useCallback, useEffect, useState } from "react"; +import { + api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, setAuthToken, + type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow, +} from "./api"; +import { RemarkCell } from "./RemarkCell"; +import { AdminPanel } from "./AdminPanel"; +import { MembersPicker } from "./MembersPicker"; +import { FoldersPicker } from "./FoldersPicker"; +import { SignIn } from "./SignIn"; + +const TOKEN_KEY = "central-token"; +const POLL_MS = 10_000; +const IDLE_MS = 10 * 60_000; // auto-logout after 10 minutes of inactivity + +type Auth = { username: string; role: string }; + +export function App() { + // ---- auth gate ---- + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ""); + const [auth, setAuth] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + const [expiredReason, setExpiredReason] = useState(null); + const isAdmin = auth?.role === "admin"; + + const signOut = useCallback((reason?: string) => { + setAuth(null); + setToken(""); + setAuthToken(""); + localStorage.removeItem(TOKEN_KEY); + setExpiredReason(reason ?? null); + }, []); + + const onSignedIn = useCallback((t: string, me: Auth) => { + setAuthToken(t); + localStorage.setItem(TOKEN_KEY, t); + setToken(t); + setAuth(me); + setExpiredReason(null); + }, []); + + // Restore a persisted session on load (silent whoami — no audit spam). + useEffect(() => { + let cancel = false; + (async () => { + if (token) { + setAuthToken(token); + try { + const me = await api.whoami(token); + if (!cancel) setAuth(me); + } catch { + if (!cancel) { setAuthToken(""); localStorage.removeItem(TOKEN_KEY); } + } + } + if (!cancel) setAuthChecked(true); + })(); + return () => { cancel = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Idle auto-logout: any activity resets a 10-minute timer. + useEffect(() => { + if (!auth) return; + let timer = 0; + const reset = () => { + clearTimeout(timer); + timer = window.setTimeout( + () => signOut("Your session expired because of inactivity. Please sign in again."), + IDLE_MS, + ); + }; + const events = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"]; + events.forEach((e) => window.addEventListener(e, reset, { passive: true })); + reset(); + return () => { clearTimeout(timer); events.forEach((e) => window.removeEventListener(e, reset)); }; + }, [auth, signOut]); + + // ---- dashboard data ---- + const [view, setView] = useState<"dashboard" | "admin">("dashboard"); + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(null); + const [stats, setStats] = useState(null); + const [activity, setActivity] = useState(null); + const [videos, setVideos] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + // Assignment (admin): selected video ids; the dropdown lists the project's members. + const [selected, setSelected] = useState>(new Set()); + const [members, setMembers] = useState([]); + const [assignee, setAssignee] = useState(""); + // All ml_support users (admin only) — the pool the members picker chooses from. + const [users, setUsers] = useState([]); + const mlUsers = users.filter((u) => u.role === "ml_support"); + + useEffect(() => { + if (!isAdmin) { setUsers([]); return; } + api.listUsers().then(setUsers).catch(() => setUsers([])); + }, [isAdmin]); + + const toggleSel = (id: number) => + setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + const loadProject = useCallback(async (id: number) => { + try { + const [s, v, a, m] = await Promise.all([ + api.stats(id), api.videos(id), api.activity(id), + api.members(id).catch(() => [] as Member[]), + ]); + setStats(s); + setVideos(v); + setActivity(a); + setMembers(m); + setError(null); + } catch (e) { + setError(String(e)); + } + }, []); + + const loadProjects = useCallback(async () => { + try { + const p = await api.projects(); + setProjects(p); + setProjectId((cur) => cur ?? (p[0]?.id ?? null)); + setError(null); + } catch (e) { + setError(String(e)); + } + }, []); + + const assignSelected = useCallback(async () => { + if (!isAdmin || selected.size === 0 || projectId == null) return; + setBusy(true); + try { + await api.assign(projectId, [...selected], assignee); + setSelected(new Set()); + await loadProject(projectId); + setError(null); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }, [isAdmin, selected, assignee, projectId, loadProject]); + + useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]); + + useEffect(() => { + if (!auth || projectId == null) return; + setSelected(new Set()); // clear selection when switching projects + void loadProject(projectId); + const t = setInterval(() => { void loadProject(projectId); void loadProjects(); }, POLL_MS); + return () => clearInterval(t); + }, [auth, projectId, loadProject, loadProjects]); + + const timer = useCallback(async (action: "start" | "stop" | "reset") => { + if (projectId == null) return; + if (action === "reset" && !window.confirm("Reset the project timer back to zero?")) return; + try { + await api.setTimer(projectId, action); + await loadProject(projectId); + } catch (e) { + setError(String(e)); + } + }, [projectId, loadProject]); + + const toggleIgnore = useCallback(async (v: VideoRow) => { + if (!isAdmin || projectId == null) return; + try { + await api.setIgnore(v.id, !v.ignored); + await loadProject(projectId); + } catch (e) { + setError(String(e)); + } + }, [isAdmin, projectId, loadProject]); + + const reingest = useCallback(async () => { + if (projectId == null) return; + setBusy(true); + try { + await api.ingest(projectId); + await loadProject(projectId); + await loadProjects(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }, [projectId, loadProject, loadProjects]); + + if (!authChecked) { + return
Loading…
; + } + if (!auth) { + return ; + } + + const project = projects.find((p) => p.id === projectId) ?? null; + const ov = stats?.overview; + const groups = groupByClient(projects); + + return ( +
+
+
Verification Dashboard
+
+ + +
+ {view === "dashboard" && ( + <> + + + + )} + + {view === "dashboard" && project && ( + + {project.client_name ? `${project.client_name} · ` : ""}{project.source_path} + + )} + {auth.username} · {auth.role} + +
+ + {error &&
⚠ {error}
} + + {view === "admin" && } + + {view === "dashboard" && ( + <> + {/* Overview */} + {ov && ( +
+ + + + +
+
Verified progress
+
+
+
+
{ov.pct_done.toFixed(1)}% + {stats?.timeline.eta_ms != null && ( + · ETA ~{fmtDuration(stats.timeline.eta_ms)} + )} +
+
+
+ )} + + {/* Project time & estimate (driven by the assigned users) */} + {stats && ov && ( +
+
+

Project time & estimate

+ {isAdmin && projectId != null && ( +
+ + {stats.timeline.time_running ? "● running" : "⏸ stopped"} + + {stats.timeline.time_running + ? + : } + +
+ )} +
+
+
+ Total project time + {fmtDuration(stats.timeline.elapsed_ms)} {stats.timeline.all_verified ? "✓" : ""} + inception → {stats.timeline.all_verified ? "all verified" : "now (ongoing)"} +
+
+ Total hours — all users + {fmtDuration(stats.timeline.total_spent_ms)} + annotation + verify, summed +
+
+ Assigned users + {stats.timeline.active_workers} + {stats.timeline.active_workers === 0 ? "assign users to estimate" : "drives the estimate"} +
+
+ Avg per video + {fmtDuration(stats.timeline.avg_video_ms)} + over verified videos +
+
+ Estimated time to finish + {stats.timeline.eta_ms != null ? fmtDuration(stats.timeline.eta_ms) : "—"} + + {ov.total - ov.verified} left ÷ {Math.max(1, stats.timeline.active_workers)} user(s) @ {fmtDuration(stats.timeline.avg_video_ms)}/video + +
+
+
+ )} + +
+ {/* Leaderboard + project members picker */} +
+
+

Per-user leaderboard

+ {isAdmin && projectId != null && ( + loadProject(projectId)} + /> + )} +
+ {isAdmin && members.length === 0 && ( +
No users assigned to this project yet — use + Assign users to pick ml_support, then assign them videos.
+ )} + + + + + + {stats?.leaderboard.map((r) => ( + + + + + + + + ))} + {(!stats || stats.leaderboard.length === 0) && ( + + )} + +
UserVideosAnnotationsTotal timeAvg / video
{r.user}{r.videos}{r.annotations}{fmtDuration(r.total_time_ms)}{fmtDuration(r.avg_time_ms)}
no annotators yet
+
+ + {/* Verification pace (rate-based, replaces the old per-day bars) */} +
+

Verification pace

+ {stats && ov ? (() => { + const t = stats.timeline; + const perDay = t.recent_window_days > 0 ? t.recent_verified / t.recent_window_days : 0; + const remaining = ov.total - ov.verified; + const paceDays = perDay > 0 ? Math.ceil(remaining / perDay) : null; + return ( +
+
+ Verified / day + {perDay.toFixed(1)} + last {t.recent_window_days} days ({t.recent_verified} done) +
+
+ Avg per video + {fmtDuration(t.avg_video_ms)} + over verified videos +
+
+ Finish at recent pace + {paceDays != null ? `~${paceDays} day${paceDays === 1 ? "" : "s"}` : "—"} + {perDay > 0 ? `${remaining} left ÷ ${perDay.toFixed(1)}/day (all users' actual output)` : "no recent activity"} +
+
+ Est. to finish · {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"} + {t.eta_ms != null ? fmtDuration(t.eta_ms) : "—"} + {remaining} left × {fmtDuration(t.avg_video_ms)} ÷ {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"} +
+
+ ); + })() :
no data yet
} +
+
+ +
+ {/* Verification leaderboard — assigned users ranked by completion speed */} +
+

Verification leaderboard · fastest first

+ + + + + + {stats?.verifiers.map((r) => ( + + + + + + + + + ))} + {(!stats || stats.verifiers.length === 0) && ( + + )} + +
RankUserVerifiedSpeed (avg/video)AnnotationsTotal time
{r.rank > 0 ? (r.rank === 1 ? "🥇 1" : `#${r.rank}`) : }{r.user}{r.videos > 0 ? r.videos : 0}{r.videos > 0 ? fmtDuration(r.avg_time_ms) : }{r.annotations > 0 ? r.annotations : 0}{r.videos > 0 ? fmtDuration(r.total_time_ms) : }
no assigned users yet
+
+ + {/* Recent activity (live claims + event feed) */} +
+

Recent activity

+ {activity && activity.active_claims.length > 0 && ( +
+ {activity.active_claims.map((c) => ( +
+ 🔒 {c.claimed_by} has {c.file_name} · since {fmtWhen(c.claimed_at)} +
+ ))} +
+ )} +
+ {activity?.recent_events.map((e, i) => ( +
+ {e.event} + {e.file_name} + {e.username} · {fmtWhen(e.at)} +
+ ))} + {(!activity || activity.recent_events.length === 0) &&
no activity yet
} +
+
+
+ + {/* Per-video table */} +
+
+

Videos ({videos.length})

+
+ {isAdmin && projectId != null && ( + loadProject(projectId)} /> + )} + {isAdmin && ( + + {selected.size} selected → + + + {members.length === 0 && — add members in Admin first} + + )} + {projectId != null && videos.some((v) => v.annotation_count > 0) && ( + + ⬇ Export all + + )} +
+
+ + + + {isAdmin && } + + + + + + {videos.map((v, idx) => ( + + {isAdmin && } + + + + + + + + + + + + + ))} + {videos.length === 0 && } + +
0 && selected.size === videos.length} + onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} />#FolderFileStatusAnnotatorAnnotationsReviewVerify timeResolutionAnnotatedRemarks
toggleSel(v.id)} />{idx + 1}{subfolder(v.rel_path) || "—"}{v.file_name} + {v.workflow_status} + {v.ignored && ignored} + {isAdmin && ( + + )} + {v.claimed_by ? ( +
+ 🔒 claimed by {v.claimed_by}{v.claimed_at ? ` · ${fmtWhen(v.claimed_at)}` : ""} +
+ ) : v.workflow_status === "assigned" && v.assigned_to ? ( +
→ assigned to {v.assigned_to}
+ ) : v.completed_at ? ( +
{v.verifiers || v.completed_by} · {fmtWhen(v.completed_at)}
+ ) : null} +
{v.verifiers || v.primary_annotator || (v.assigned_to ? assigned: {v.assigned_to} : "—")} + {v.annotation_count > 0 + ? {v.annotation_count} ⬇ + : 0} + {v.imported_count > 0 && ( +
+ imported {v.imported_count} + {v.annotation_count < v.imported_count && + · −{v.imported_count - v.annotation_count}} + {v.annotation_count > v.imported_count && + · +{v.annotation_count - v.imported_count}} +
+ )} +
{v.review_count > 0 + ? 🚩 {v.review_count} + : }{fmtDuration(v.verify_time_ms)}{v.width && v.height ? `${v.width}×${v.height}` : "—"}{v.annotated_at ? v.annotated_at.slice(0, 10) : "—"} + +
no videos ingested
+
+ + )} +
+ ); +} + +/** The subfolder portion of a rel_path (everything before the last '/'), or "" at the root. */ +function subfolder(rel: string): string { + const i = rel.lastIndexOf("/"); + return i >= 0 ? rel.slice(0, i) : ""; +} + +/** Group projects under their client name, preserving the server's ordering. */ +function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][] { + const order: string[] = []; + const map = new Map(); + for (const p of projects) { + const key = p.client_name || "—"; + if (!map.has(key)) { map.set(key, []); order.push(key); } + map.get(key)!.push(p); + } + return order.map((k) => [k, map.get(k)!]); +} + +function Card({ label, value, tone }: { label: string; value: number; tone?: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/dashboard/src/FoldersPicker.tsx b/dashboard/src/FoldersPicker.tsx new file mode 100644 index 0000000..08f1333 --- /dev/null +++ b/dashboard/src/FoldersPicker.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, type Folder } from "./api"; + +/** + * Dropdown checklist of the project's folders. Ticking folders restricts the videos + * shown/counted to those folders (admins verify only some folders; the rest are + * backup/misc). None ticked = consider all folders. Admin only. + */ +export function FoldersPicker({ projectId, onChange }: { projectId: number; onChange: () => void }) { + const [open, setOpen] = useState(false); + const [folders, setFolders] = useState([]); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const ref = useRef(null); + + const load = useCallback(async () => { + try { setFolders(await api.folders(projectId)); } catch (e) { setErr(String(e)); } + }, [projectId]); + + useEffect(() => { if (open) void load(); }, [open, load]); + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", h); + return () => document.removeEventListener("mousedown", h); + }, [open]); + + const includedCount = folders.filter((f) => f.included).length; + + const commit = async (next: Folder[]) => { + setFolders(next); + setBusy(true); + setErr(null); + try { + await api.setFolders(projectId, next.filter((f) => f.included).map((f) => f.folder)); + onChange(); + } catch (e) { + setErr(String(e)); + await load(); + } finally { + setBusy(false); + } + }; + + const toggle = (folder: string) => + commit(folders.map((f) => (f.folder === folder ? { ...f, included: !f.included } : f))); + const considerAll = () => commit(folders.map((f) => ({ ...f, included: false }))); + + const label = includedCount === 0 ? "Folders: all ▾" : `Folders: ${includedCount}/${folders.length} ▾`; + + return ( +
+ + {open && ( +
+
Tick folders to verify. None ticked = all folders shown.
+
+ {folders.map((f) => ( + + ))} + {folders.length === 0 &&
no folders — sync the project first
} +
+ {err &&
⚠ {err}
} +
+ + + {includedCount === 0 ? "all folders shown" : `${includedCount} selected`} + +
+
+ )} +
+ ); +} diff --git a/dashboard/src/MembersPicker.tsx b/dashboard/src/MembersPicker.tsx new file mode 100644 index 0000000..dbf6d0b --- /dev/null +++ b/dashboard/src/MembersPicker.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from "react"; +import { api, type Member, type UserRow } from "./api"; + +/** + * Dropdown checklist to pick which ml_support users belong to a project. Searchable + * + scrollable (built for ~30 users). Ticking a user adds them as a project member; + * un-ticking removes them. Members are who can see the project and be assigned videos. + * Admin only. By default no one is selected. + */ +export function MembersPicker({ + projectId, + users, + members, + onChange, +}: { + projectId: number; + users: UserRow[]; // all ml_support users + members: Member[]; + onChange: () => void; // reload the project's members after a change +}) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [busy, setBusy] = useState(null); + const [err, setErr] = useState(null); + const ref = useRef(null); + + const memberSet = new Set(members.map((m) => m.username)); + + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", h); + return () => document.removeEventListener("mousedown", h); + }, [open]); + + const toggle = async (username: string) => { + setBusy(username); + setErr(null); + try { + if (memberSet.has(username)) await api.removeMember(projectId, username); + else await api.addMember(projectId, username); + onChange(); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(null); + } + }; + + const filtered = users.filter((u) => u.username.toLowerCase().includes(q.trim().toLowerCase())); + + return ( +
+ + {open && ( +
+ setQ(e.target.value)} + autoFocus + /> +
+ {filtered.map((u) => ( + + ))} + {users.length === 0 &&
no ml_support users yet — create some in Admin
} + {users.length > 0 && filtered.length === 0 &&
no matches
} +
+ {err &&
⚠ {err}
} +
{members.length} selected · ticked users can be assigned videos
+
+ )} +
+ ); +} diff --git a/dashboard/src/RemarkCell.tsx b/dashboard/src/RemarkCell.tsx new file mode 100644 index 0000000..2afbd92 --- /dev/null +++ b/dashboard/src/RemarkCell.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import { api, type Remark } from "./api"; + +/** + * Remark + raise-hand cell for the dashboard video table. Collapsed shows just a + * count (💬 N) so it never overflows the column; expand for the full thread + an add + * box. The ✋ raise-hand toggle lets anyone start a conversation on a video; anyone + * (user or admin) can lower it. Both use the signed-in token automatically. + */ +export function RemarkCell({ + videoId, + initial, + handRaised, + handBy, +}: { + videoId: number; + initial: Remark[]; + handRaised: boolean; + handBy: string | null; +}) { + const [thread, setThread] = useState(initial ?? []); + const [raised, setRaised] = useState(handRaised); + const [by, setBy] = useState(handBy); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const add = async () => { + const body = draft.trim(); + if (!body) return; + setBusy(true); + setErr(null); + try { + setThread(await api.addRemark(videoId, body)); + setDraft(""); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const toggleHand = async () => { + const next = !raised; + setBusy(true); + setErr(null); + try { + const r = await api.setHand(videoId, next); + setRaised(r.hand_raised); + setBy(r.hand_raised ? r.by : null); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const handBtn = ( + + ); + + if (!open) { + return ( +
+ {handBtn} + +
+ ); + } + + return ( +
+
+ {handBtn} + {raised && ✋ {by}} + + +
+
+ {thread.length === 0 &&
No remarks yet.
} + {thread.map((r, i) => ( +
{r.username}: {r.body}
+ ))} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") add(); }} + placeholder="Add a remark…" + autoFocus + /> + +
+ {err &&
⚠ {err}
} +
+ ); +} diff --git a/dashboard/src/SignIn.tsx b/dashboard/src/SignIn.tsx new file mode 100644 index 0000000..a9fd667 --- /dev/null +++ b/dashboard/src/SignIn.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { api } from "./api"; + +/** + * Full-screen sign-in gate. No data is shown until a valid token is entered — this + * applies to both admins and ml_support. `reason` shows why a previous session ended + * (e.g. expired due to inactivity). + */ +export function SignIn({ + onSignedIn, + reason, +}: { + onSignedIn: (token: string, auth: { username: string; role: string }) => void; + reason?: string | null; +}) { + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const signIn = async () => { + const t = token.trim(); + if (!t) return; + setBusy(true); + setErr(null); + try { + const auth = await api.login(t); // validates + records a signed_in audit event + onSignedIn(t, auth); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

Verification Dashboard

+

Sign in with your access token to continue.

+ {reason &&
⏱ {reason}
} + setToken(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") signIn(); }} + autoFocus + /> + + {err &&
⚠ {err}
} +
+
+ ); +} diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts new file mode 100644 index 0000000..e600e62 --- /dev/null +++ b/dashboard/src/api.ts @@ -0,0 +1,308 @@ +const BASE = (import.meta.env.VITE_API_BASE as string) || "/api"; + +export interface ProjectSummary { + id: number; + name: string; + source_path: string; + source_kind: string; // 'local' | 'nfs' + client_id: number | null; + client_name: string; + total: number; + pending: number; + annotated: number; + verified: number; +} + +export interface UserRow { + id: number; + username: string; + display_name: string; + role: string; + active: boolean; + created_at: string; +} + +export interface AuditRow { + at: string; + username: string; + action: string; + detail: Record; +} + +export interface Client { + id: number; + name: string; + created_at: string; + project_count: number; +} + +export interface Remark { + username: string; + body: string; + created_at: string; +} + +export interface VideoRow { + id: number; + file_name: string; + rel_path: string; + has_json: boolean; + width: number | null; + height: number | null; + fps: number | null; + frame_count: number | null; + annotation_count: number; + imported_count: number; // baseline at claim/ingest — vs annotation_count shows deletions + annotation_time_ms: number; + primary_annotator: string; + status: string; + annotated_at: string | null; + // Phase 3 claim/verify dimension (orthogonal to status). + claimed_by: string | null; + claimed_at: string | null; + lease_expires_at: string | null; + completed_by: string | null; + completed_at: string | null; + verify_time_ms: number; // total across all passes + verify_count: number; // 0 none, 1 verified, ≥2 re-verified + verifiers: string; // distinct verifiers, e.g. "ravi, john" + review_count: number; // annotations flagged for review still open + assigned_to: string | null; + workflow_status: string; // pending|annotated|assigned|in-progress|verified|re-verified + hand_raised: boolean; + hand_raised_by: string | null; + hand_raised_at: string | null; + ignored: boolean; + ignored_by: string | null; + remarks: Remark[]; +} + +export interface LeaderRow { + user: string; + videos: number; + annotations: number; + total_time_ms: number; + avg_time_ms: number; +} + +export interface VerifierRow { + user: string; + videos: number; + total_time_ms: number; + avg_time_ms: number; + annotations: number; // total annotations this user authored + rank: number; // 1 = fastest; 0 = no completions yet +} + +export interface ProjectTime { + active_workers: number; + avg_video_ms: number; + total_spent_ms: number; + elapsed_ms: number; + all_verified: boolean; + eta_ms: number | null; + time_running: boolean; + last_activity_at: string | null; + recent_verified: number; + recent_window_days: number; +} + +export interface Folder { + folder: string; // '' = project root + video_count: number; + included: boolean; +} + +export interface Stats { + overview: { + total: number; + pending: number; + annotated: number; + verified: number; + pct_done: number; + }; + leaderboard: LeaderRow[]; + verifiers: VerifierRow[]; + timeline: ProjectTime; +} + +export interface Member { + username: string; + display_name: string; + role: string; + added_at: string; +} + +export interface StorageInfo { + db_bytes: number; + videos: number; + annotations: number; + video_events: number; + audit_events: number; + max_log_rows: number; +} + +export interface ActiveClaim { + video_id: number; + file_name: string; + claimed_by: string; + claimed_at: string; + lease_expires_at: string; +} + +export interface ActivityEvent { + video_id: number; + file_name: string; + username: string; + event: string; // claim | release | push | auto_release | reopen + at: string; +} + +export interface Activity { + active_claims: ActiveClaim[]; + recent_events: ActivityEvent[]; +} + +// The signed-in bearer token. Set once after login (App gate); every request +// attaches it automatically so the whole dashboard is behind auth. +let authToken = ""; +export function setAuthToken(t: string) { + authToken = t; +} + +/** Download URLs for annotation export. Served as attachments, which + * can't set a header — so the token rides as a query param (validated server-side). */ +export const exportVideoUrl = (id: number) => + `${BASE}/videos/${id}/export?token=${encodeURIComponent(authToken)}`; +export const exportProjectUrl = (id: number) => + `${BASE}/projects/${id}/export?token=${encodeURIComponent(authToken)}`; + +function authHeaders(token?: string): Record { + const t = token ?? authToken; + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +async function get(path: string, token?: string): Promise { + const r = await fetch(`${BASE}${path}`, { headers: authHeaders(token) }); + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json(); +} + +/** POST with optional JSON body; uses the explicit token or the signed-in one. */ +async function post(path: string, body?: unknown, token?: string): Promise { + const headers: Record = { ...authHeaders(token) }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const r = await fetch(`${BASE}${path}`, { + method: "POST", + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json(); +} + +/** DELETE with the signed-in (or explicit) token. */ +async function del(path: string, token?: string): Promise { + const r = await fetch(`${BASE}${path}`, { method: "DELETE", headers: authHeaders(token) }); + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json(); +} + +export const api = { + projects: () => get("/projects"), + clients: () => get("/clients"), + videos: (id: number) => get(`/projects/${id}/videos`), + stats: (id: number) => get(`/projects/${id}/stats`), + activity: (id: number) => get(`/projects/${id}/activity`), + audit: (limit = 100) => get(`/audit?limit=${limit}`), + // Sync re-scans the project's NAS folder; the token (if any) attributes the action. + ingest: (id: number, token?: string) => + post<{ ingested: number; source_path: string }>(`/projects/${id}/ingest`, undefined, token), + whoami: async (token: string): Promise<{ username: string; role: string }> => { + const r = await fetch(`${BASE}/auth/whoami`, { headers: { Authorization: `Bearer ${token}` } }); + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json(); + }, + // Explicit sign-in: validates the token AND records a `signed_in` audit event. + login: (token: string) => post<{ username: string; role: string }>("/auth/login", undefined, token), + // Any authenticated user: add a project, change its sync directory. + createProject: ( + p: { client_id: number; name: string; source_path: string; source_kind: string }, + token?: string, + ) => post<{ id: number }>("/projects", p, token), + updateProjectSource: ( + id: number, + p: { source_path: string; source_kind: string }, + token?: string, + ) => post<{ id: number }>(`/projects/${id}/source`, p, token), + // Admin only. + createClient: (name: string, token?: string) => post("/clients", { name }, token), + deleteClient: (id: number, token?: string) => del<{ deleted: boolean; projects_removed: number }>(`/clients/${id}`, token), + deleteProject: (id: number, token?: string) => del<{ deleted: boolean }>(`/projects/${id}`, token), + deleteUser: (id: number, token?: string) => del<{ deleted: boolean; username: string }>(`/users/${id}`, token), + listUsers: (token?: string) => get("/users", token), + createUser: ( + u: { username: string; display_name?: string; role: string }, + token?: string, + ) => post<{ username: string; role: string; token: string }>("/users", u, token), + // Admin only: regenerate a user's token (when they forget it). Returns the new token once. + resetUserToken: (id: number, token?: string) => + post<{ username: string; role: string; token: string }>(`/users/${id}/token`, undefined, token), + // Admin only: edit a user's display name + role. + updateUser: (id: number, body: { display_name: string; role: string }, token?: string) => + post<{ updated: boolean; display_name: string; role: string }>(`/users/${id}/update`, body, token), + // Append a remark (any authed user). Returns the updated thread. + addRemark: (videoId: number, body: string, token?: string) => + post(`/videos/${videoId}/remarks`, { body }, token), + // Raise/lower the hand on a video (any authed user). Anyone can lower it. + setHand: (videoId: number, raised: boolean, token?: string) => + post<{ hand_raised: boolean; by: string }>(`/videos/${videoId}/hand`, { raised }, token), + // Ignore / un-ignore a video (admin only). + setIgnore: (videoId: number, ignored: boolean, token?: string) => + post<{ ignored: boolean }>(`/videos/${videoId}/ignore`, { ignored }, token), + // Assign (or unassign with assignee="") videos to a member. Any authed user. + assign: (projectId: number, video_ids: number[], assignee: string, token?: string) => + post<{ assigned: number; assignee: string }>(`/projects/${projectId}/assign`, { video_ids, assignee }, token), + // Project membership (admin picks who can see/be-assigned a project). + members: (projectId: number) => get(`/projects/${projectId}/members`), + addMember: (projectId: number, username: string, token?: string) => + post<{ added: boolean }>(`/projects/${projectId}/members`, { username }, token), + removeMember: (projectId: number, username: string, token?: string) => + del<{ removed: boolean }>(`/projects/${projectId}/members/${encodeURIComponent(username)}`, token), + // Admin: DB size + row counts (how much space the app uses). + storage: () => get("/admin/storage"), + // Project timer (admin): start | stop | reset. + setTimer: (projectId: number, action: "start" | "stop" | "reset", token?: string) => + post<{ action: string }>(`/projects/${projectId}/timer`, { action }, token), + // Folders to consider for verification. + folders: (projectId: number) => get(`/projects/${projectId}/folders`), + setFolders: (projectId: number, folders: string[], token?: string) => + post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token), +}; + +/** Human-readable byte size. */ +export function fmtBytes(n: number): string { + if (!n || n <= 0) return "0 B"; + const u = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`; +} + +/** Readable local date+time for a server ISO timestamp; "" when missing/invalid. */ +export function fmtWhen(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +export function fmtDuration(ms: number): string { + if (!ms || ms <= 0) return "—"; + const s = Math.round(ms / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..39fb1c9 --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css new file mode 100644 index 0000000..47cd450 --- /dev/null +++ b/dashboard/src/styles.css @@ -0,0 +1,254 @@ +:root { + --bg0: #0e1222; --bg1: #151a30; --bg2: #1c223d; --border: #283154; + --text: #e8e9f1; --dim: #8f95ae; --accent: #f5c838; + --ok: #5ccf75; --info: #6ea8ff; --warn: #f5a623; --danger: #ff7a7a; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg0); color: var(--text); + font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } +.page { max-width: 1200px; margin: 0 auto; padding: 16px; } +.dim { color: var(--dim); } +.mono, .path { font-family: ui-monospace, Menlo, monospace; font-size: 12px; } + +.topbar { display: flex; align-items: center; gap: 12px; padding: 10px 0 16px; } +.brand { font-size: 18px; font-weight: 700; } +.spacer { flex: 1; } +.topbar select, .topbar button { + background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; +} +.topbar button:hover:not(:disabled) { background: var(--border); } +.topbar button:disabled { opacity: 0.5; cursor: default; } +.path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.error { background: rgba(255,122,122,0.12); border: 1px solid var(--danger); + color: #ffb3a8; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; } + +.cards { display: grid; grid-template-columns: repeat(4, 1fr) 1.6fr; gap: 12px; margin-bottom: 16px; } +.card { background: var(--bg1); border: 1px solid var(--border); border-radius: 10px; padding: 14px; } +.card-label { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; } +.card-value { font-size: 30px; font-weight: 700; margin-top: 4px; } +.card-value.sm { font-size: 16px; } +.card.ok .card-value { color: var(--ok); } +.card.info .card-value { color: var(--info); } +.card.warn .card-value { color: var(--warn); } +.progress-card { display: flex; flex-direction: column; justify-content: center; } +.bar { height: 10px; background: var(--bg2); border-radius: 6px; overflow: hidden; margin: 8px 0; } +.bar-fill { height: 100%; background: var(--ok); transition: width 0.4s; } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; } +@media (max-width: 860px) { .grid2 { grid-template-columns: 1fr; } .cards { grid-template-columns: repeat(2, 1fr); } } +.panel { background: var(--bg1); border: 1px solid var(--border); border-radius: 10px; padding: 14px; } +.panel h3 { margin: 0 0 10px; font-size: 14px; } + +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 13px; } +th { color: var(--dim); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } +tbody tr:hover { background: rgba(255,255,255,0.02); } + +.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; } +.badge.pending { background: rgba(245,166,35,0.18); color: var(--warn); } +.badge.annotated { background: rgba(110,168,255,0.18); color: var(--info); } +.badge.verified { background: rgba(92,207,117,0.18); color: var(--ok); } + +.throughput { display: flex; flex-direction: column; gap: 6px; } +.tp-row { display: grid; grid-template-columns: 90px 1fr 34px; align-items: center; gap: 8px; } +.tp-date { font-size: 11px; } +.tp-bar { height: 12px; background: var(--bg2); border-radius: 6px; overflow: hidden; } +.tp-fill { height: 100%; background: var(--accent); } +.tp-count { text-align: right; font-variant-numeric: tabular-nums; } + +/* View toggle (Dashboard | Admin) */ +.viewtabs { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.viewtabs button { + background: var(--bg1); color: var(--dim); border: none; padding: 6px 14px; + cursor: pointer; font-size: 13px; +} +.viewtabs button.active { background: var(--bg2); color: var(--text); font-weight: 600; } +.viewtabs button:not(.active):hover { color: var(--text); } + +/* Admin forms */ +.admin { display: flex; flex-direction: column; gap: 16px; } +.admin .grid2 { margin-bottom: 0; } +.form-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.form-col { display: flex; flex-direction: column; gap: 6px; max-width: 480px; } +.form-col label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } +.admin input, .admin select { + background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; +} +.admin input:focus, .admin select:focus { outline: none; border-color: var(--info); } +.btn { + background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 13px; +} +.btn:hover:not(:disabled) { background: var(--border); } +.btn:disabled { opacity: 0.5; cursor: default; } +.btn.primary { background: var(--info); border-color: var(--info); color: #0a1020; font-weight: 600; align-self: flex-start; } +.btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--info); } +.ok-tag { color: var(--ok); font-size: 12px; } +.err-tag { color: var(--danger); font-size: 12px; } +.notice-ok { background: rgba(92,207,117,0.12); border: 1px solid var(--ok); + color: #b6f0c3; padding: 8px 12px; border-radius: 6px; } + +/* One-time token reveal */ +.token-box { background: rgba(245,200,56,0.08); border: 1px solid var(--accent); + border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; font-size: 13px; } +.token-val { background: var(--bg0); border: 1px solid var(--border); border-radius: 6px; + padding: 6px 8px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; + word-break: break-all; flex: 1; } + +/* Remark thread cell (video table) */ +.remark-col { max-width: 360px; } +.remark-collapsed { background: none; border: none; color: var(--fg, #dce3f5); cursor: pointer; + text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; max-width: 340px; } +.remark-collapsed:hover { filter: brightness(1.2); } +.remark-badge { background: var(--bg0); border: 1px solid var(--border); border-radius: 10px; + padding: 1px 7px; font-size: 11px; white-space: nowrap; } +.remark-snip { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.remark-open { display: flex; flex-direction: column; gap: 6px; min-width: 280px; } +.remark-thread { max-height: 120px; overflow: auto; display: flex; flex-direction: column; gap: 3px; + background: var(--bg0); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; } +.remark-line { font-size: 12px; line-height: 1.35; } +.remark-line b { color: var(--info); } +.remark-add { display: flex; gap: 6px; align-items: center; } +.remark-add input { flex: 1; background: var(--bg0); color: inherit; border: 1px solid var(--border); + border-radius: 6px; padding: 6px 8px; font-size: 12px; } + +/* Claim / completion subtitle under the status badge (video table) */ +.claim-line { margin-top: 3px; font-size: 11px; color: var(--dim); white-space: nowrap; } +.claim-line b { color: var(--fg, #dce3f5); font-weight: 600; } + +/* Flagged-for-review count (video table) */ +.review-flag { background: rgba(245,166,35,0.18); color: var(--warn); border-radius: 10px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; } + +/* Panel header row (title + actions, e.g. Export all) */ +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.panel-head h3 { margin: 0; } + +/* Recent activity feed + verification panel */ +.active-claims { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.active-claim { font-size: 12px; background: rgba(110,168,255,0.08); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; } +.activity-feed { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow: auto; } +.activity-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 2px 0; } +.evt { font-size: 10px; font-weight: 700; text-transform: uppercase; border-radius: 4px; padding: 1px 6px; min-width: 56px; text-align: center; } +.evt-push { background: rgba(92,207,117,0.18); color: var(--ok); } +.evt-claim { background: rgba(110,168,255,0.18); color: var(--info); } +.evt-release { background: rgba(245,166,35,0.18); color: var(--warn); } +.evt-auto_release { background: rgba(245,166,35,0.12); color: var(--warn); } +.evt-reopen { background: rgba(160,160,160,0.18); color: var(--dim); } + +/* Workflow-status badges (assigned / in-progress / re-verified) + assignment UI */ +.badge.assigned { background: rgba(180,140,255,0.18); color: #b48cff; } +.badge.in-progress { background: rgba(110,168,255,0.20); color: var(--info); } +.badge.re-verified { background: rgba(92,207,117,0.22); color: var(--ok); } +.panel-actions { display: flex; align-items: center; gap: 12px; } +.assign-bar { display: flex; align-items: center; gap: 6px; font-size: 12px; } +.assign-bar select { background: var(--bg0); color: inherit; border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font-size: 12px; } +tr.row-sel { background: rgba(110,168,255,0.08); } + +/* ---- Sign-in gate ---- */ +.signin-screen { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 16px; } +.signin-card { background: var(--bg1); border: 1px solid var(--border); border-radius: 12px; + padding: 28px; width: 100%; max-width: 380px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } +.signin-card h1 { margin: 0 0 4px; font-size: 20px; } +.signin-card p { margin: 0 0 16px; } +.signin-card input { width: 100%; background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 9px 11px; font-size: 14px; margin-bottom: 12px; } +.signin-card input:focus { outline: none; border-color: var(--info); } +.signin-card .btn.primary { width: 100%; justify-content: center; align-self: stretch; text-align: center; } +.signin-card .error { margin-top: 12px; margin-bottom: 0; } +.signin-reason { background: rgba(245,166,35,0.12); border: 1px solid var(--warn); color: #f3d3a0; + border-radius: 6px; padding: 8px 10px; font-size: 13px; margin-bottom: 14px; } + +/* Signed-in identity + sign out in the top bar */ +.whoami { color: var(--dim); font-size: 12px; white-space: nowrap; } +.signout-btn { background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; } +.signout-btn:hover { background: var(--border); } + +/* Danger / destructive buttons + row action group */ +.btn.danger { border-color: var(--danger); color: #ffb3a8; } +.btn.danger:hover:not(:disabled) { background: rgba(255,122,122,0.15); } +.row-actions { display: flex; gap: 6px; } + +/* Imported-vs-current annotation count (deletions/additions) */ +.imported-line { font-size: 11px; margin-top: 2px; white-space: nowrap; } +.ann-del { color: var(--danger); font-weight: 600; } +.ann-add { color: var(--ok); font-weight: 600; } + +/* Raise-hand toggle in the remark cell */ +.remark-cell { display: flex; align-items: center; gap: 6px; } +.hand-btn { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; + cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 6px; filter: grayscale(1) opacity(0.55); } +.hand-btn:hover:not(:disabled) { filter: none; } +.hand-btn.raised { filter: none; background: rgba(245,166,35,0.18); border-color: var(--warn); } +.hand-note { color: var(--warn); font-size: 11px; white-space: nowrap; } +.remark-open-head { display: flex; align-items: center; gap: 6px; } + +/* Ignored video row (admin "ignore") — crossed out + dimmed */ +tr.row-ignored td { text-decoration: line-through; opacity: 0.5; } +tr.row-ignored .badge, tr.row-ignored .ignore-btn { text-decoration: none; } +.badge.ignored-tag { background: rgba(160,160,160,0.18); color: var(--dim); margin-left: 6px; text-decoration: none; } +.ignore-btn { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 11px; + margin-left: 8px; text-decoration: underline; padding: 0; } +.ignore-btn:hover { color: var(--text); } + +/* Project time & estimate panel */ +.time-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } +.tcell { display: flex; flex-direction: column; gap: 2px; background: var(--bg2); + border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.tcell b { font-size: 18px; } +.tcell .sub { font-size: 11px; } +.tcell.big { background: rgba(110,168,255,0.08); border-color: rgba(110,168,255,0.35); } +.tcell.big b { font-size: 20px; } + +/* Members picker (dropdown checklist on the leaderboard panel) */ +.members-picker { position: relative; } +.members-dropdown { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; width: 280px; + background: var(--bg1); border: 1px solid var(--border); border-radius: 8px; + box-shadow: 0 10px 30px rgba(0,0,0,0.45); padding: 8px; } +.members-search { width: 100%; background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 6px 8px; font-size: 13px; margin-bottom: 6px; } +.members-list { max-height: 260px; overflow: auto; display: flex; flex-direction: column; gap: 1px; } +.members-item { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; } +.members-item:hover { background: var(--bg2); } +.members-item.on { background: rgba(92,207,117,0.10); } +.members-item .mi-disp { font-size: 11px; margin-left: auto; } +.mi-empty { padding: 8px; font-size: 12px; } +.mi-err { color: var(--danger); font-size: 12px; padding: 4px 6px; } +.members-foot { font-size: 11px; border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; } +.members-cta { background: rgba(245,166,35,0.10); border: 1px solid rgba(245,166,35,0.4); + color: #f3d3a0; border-radius: 6px; padding: 7px 10px; font-size: 12px; margin-bottom: 10px; } +.folders-hint { font-size: 11px; padding: 2px 4px 6px; } + +/* Verification pace panel */ +.pace-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; } +.pcell { display: flex; flex-direction: column; gap: 2px; background: var(--bg2); + border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.pcell b { font-size: 18px; } +.pcell .sub { font-size: 11px; } +.pcell.big { background: rgba(110,168,255,0.08); border-color: rgba(110,168,255,0.35); } +tr.rank-top { background: rgba(245,200,56,0.08); } + +/* Project timer controls */ +.timer-controls { display: flex; align-items: center; gap: 8px; } +.timer-state { font-size: 12px; font-weight: 600; } +.timer-state.on { color: var(--ok); } +.timer-state.off { color: var(--dim); } + +/* Project members manager (Admin) */ +.members-box { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; } +.members-box h4 { margin: 0 0 8px; font-size: 13px; } +.member-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.member-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg2); + border: 1px solid var(--border); border-radius: 14px; padding: 3px 6px 3px 10px; font-size: 12px; } +.chip-x { background: none; border: none; color: var(--danger); cursor: pointer; font-size: 14px; + line-height: 1; padding: 0 2px; } +.chip-x:hover { filter: brightness(1.2); } + +/* Storage panel */ +.storage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } +.storage-grid > div { display: flex; flex-direction: column; gap: 2px; background: var(--bg2); + border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.storage-grid b { font-size: 18px; } diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.tsbuildinfo b/dashboard/tsconfig.tsbuildinfo new file mode 100644 index 0000000..57736cb --- /dev/null +++ b/dashboard/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/FoldersPicker.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..69c6351 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + // `npm run dev` proxies /api to the server so the app is same-origin in dev. + proxy: { + "/api": { target: "http://localhost:8080", changeOrigin: true }, + }, + }, +}); diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..52fca7c --- /dev/null +++ b/db/init.sql @@ -0,0 +1,224 @@ +-- Central verification DB (Phase 1: dashboard pipeline). +-- Ingest reads a folder of videos + sibling export-JSONs and fills these tables. + +CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + source_path TEXT NOT NULL, -- local folder now; mounted NAS share on the VM + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS videos ( + id SERIAL PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + rel_path TEXT NOT NULL, + has_json BOOLEAN NOT NULL DEFAULT FALSE, + width INTEGER, + height INTEGER, + fps DOUBLE PRECISION, + frame_count INTEGER, + annotation_count INTEGER NOT NULL DEFAULT 0, + annotation_time_ms BIGINT NOT NULL DEFAULT 0, + primary_annotator TEXT NOT NULL DEFAULT '', + -- 'pending' = no JSON yet (not annotated) + -- 'annotated'= has annotations from the NAS JSON (awaiting verification) + -- 'verified' = a worker pushed a verified pass (Phase 3) + status TEXT NOT NULL DEFAULT 'pending', + annotated_at TIMESTAMPTZ, -- max(annotation.created_at), used for throughput + raw_json JSONB, -- full imported export doc (for Phase 3 pull) + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, rel_path) +); + +CREATE INDEX IF NOT EXISTS idx_videos_project ON videos(project_id); +CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(project_id, status); + +CREATE TABLE IF NOT EXISTS annotations ( + id SERIAL PRIMARY KEY, + video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + frame_number INTEGER NOT NULL, + label TEXT NOT NULL, + side TEXT NOT NULL DEFAULT '', + shape_type TEXT NOT NULL DEFAULT 'bbox', + vertices JSONB, + annotated_by TEXT NOT NULL DEFAULT '', + review_status TEXT NOT NULL DEFAULT 'none', + remark TEXT NOT NULL DEFAULT '', + subclass TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_annotations_video ON annotations(video_id); +CREATE INDEX IF NOT EXISTS idx_annotations_user ON annotations(annotated_by); + +-- --------------------------------------------------------------------------- +-- Phase 3: worker pull/push (claim → verify → push) + token auth. +-- These run on every startup; ALTER ... IF NOT EXISTS keeps them idempotent and +-- migrates existing Postgres volumes without a separate migration step. +-- --------------------------------------------------------------------------- + +-- Provisioned workers/admins. Tokens are never stored in clear: token_hash is +-- the lowercase hex SHA-256 of the issued token. Admin provisions these. +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'ml_support', -- 'ml_support' | 'admin' + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_token ON users(token_hash); + +-- Roles were renamed worker → ml_support (only two roles now: ml_support | admin). +-- Idempotent: migrate existing rows + the column default on already-provisioned DBs. +UPDATE users SET role='ml_support' WHERE role='worker'; +ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ml_support'; + +-- Claiming is orthogonal to the annotation `status` above: a worker claims an +-- 'annotated' video (exclusive lease), verifies it, then pushes → 'verified'. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS claimed_by TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS completed_by TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS verify_time_ms BIGINT NOT NULL DEFAULT 0; + +-- A video is claimable when annotated and either unclaimed or its lease lapsed. +CREATE INDEX IF NOT EXISTS idx_videos_claim ON videos(project_id, claimed_by, lease_expires_at); + +-- Imported baseline: how many annotations the video started a pass with (NAS-ingested +-- count, or the worker's claim-time count on push). vs annotation_count it reveals how +-- many the worker added/deleted during verification. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS imported_count INTEGER NOT NULL DEFAULT 0; + +-- "Raise hand" signal: anyone (ml_support or admin) can raise a hand on a video to +-- start a conversation; anyone can lower it. Orthogonal to the remark thread. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS hand_raised BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS hand_raised_by TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS hand_raised_at TIMESTAMPTZ; + +-- "Ignore" flag: admins can mark a video as ignored (excluded from the workflow); +-- the dashboard/desktop cross out the row. Admin-only to set. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS ignored BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS ignored_by TEXT; + +-- Admin assignment: which user a video is assigned to (advisory owner). Orthogonal to +-- claim/status; drives the 'assigned' workflow state until someone claims it. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS assigned_to TEXT; +CREATE INDEX IF NOT EXISTS idx_videos_assigned ON videos(project_id, assigned_to); + +-- Audit + analytics: every claim/release/push/auto_release/ingest. +CREATE TABLE IF NOT EXISTS video_events ( + id SERIAL PRIMARY KEY, + video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + username TEXT NOT NULL DEFAULT '', + event TEXT NOT NULL, -- 'claim'|'release'|'push'|'auto_release'|'reopen' + at TIMESTAMPTZ NOT NULL DEFAULT now(), + meta JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX IF NOT EXISTS idx_events_video ON video_events(video_id); +CREATE INDEX IF NOT EXISTS idx_events_at ON video_events(at); + +-- --------------------------------------------------------------------------- +-- Clients own projects (one client → many projects). Admins create these from +-- the dashboard; each project points at a NAS folder (`source_path`). +-- Idempotent: safe to re-run on every startup. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- projects gains a client owner; name becomes unique PER client +-- (Microsoft/India and Google/India can both exist). +ALTER TABLE projects ADD COLUMN IF NOT EXISTS client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE; + +-- One-time backfill: only if a client-less project exists, park it under a 'default' client. +-- Guarded so a deleted 'default' is NOT resurrected on every boot. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM projects WHERE client_id IS NULL) THEN + INSERT INTO clients (name) VALUES ('default') ON CONFLICT (name) DO NOTHING; + UPDATE projects SET client_id = (SELECT id FROM clients WHERE name='default') WHERE client_id IS NULL; + END IF; +END $$; + +-- Swap the old global UNIQUE(name) for UNIQUE(client_id, name). DROP IF EXISTS is idempotent; +-- ADD CONSTRAINT is guarded by a DO-block (Postgres has no ADD CONSTRAINT IF NOT EXISTS). +ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_name_key; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='projects_client_name_key') THEN + ALTER TABLE projects ADD CONSTRAINT projects_client_name_key UNIQUE (client_id, name); + END IF; +END $$; +CREATE INDEX IF NOT EXISTS idx_projects_client ON projects(client_id); + +-- How to reach a project's source folder. 'local' = scan source_path directly (a path inside the +-- container, incl. compose-managed NFS volumes); 'nfs' = mount NFS_HOST:source_path on demand +-- (the NAS host/IP lives only in the NFS_HOST env, never here). +ALTER TABLE projects ADD COLUMN IF NOT EXISTS source_kind TEXT NOT NULL DEFAULT 'local'; + +-- --------------------------------------------------------------------------- +-- Audit log of org-level actions (client/project created, sync dir changed, +-- synced, user created) with timestamp + actor. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS audit_events ( + id SERIAL PRIMARY KEY, + at TIMESTAMPTZ NOT NULL DEFAULT now(), + username TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, -- 'client_created'|'project_created'|'project_source_changed'|'synced'|'user_created' + detail JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_events(at); + +-- --------------------------------------------------------------------------- +-- Per-video remark thread. Admins and workers both append; everyone sees the +-- same thread (synced). Each line is attributed to its author + timestamp. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS video_remarks ( + id SERIAL PRIMARY KEY, + video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + username TEXT NOT NULL, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_video_remarks_video ON video_remarks(video_id, created_at); + +-- --------------------------------------------------------------------------- +-- Project membership: which users are picked for a project. Admins manage this. +-- Non-admins only see projects they're a member of; videos can only be assigned +-- to members. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS project_members ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + username TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, username) +); +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(username); + +-- --------------------------------------------------------------------------- +-- Project timer: a stopwatch for "active project time". Admins start/stop/reset it; +-- a push (verify/re-verify) auto-resumes it; 72h of inactivity auto-stops it (idle +-- time isn't counted). elapsed = accumulated + (running ? now - started_at : 0). +-- --------------------------------------------------------------------------- +ALTER TABLE projects ADD COLUMN IF NOT EXISTS time_running BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS time_accumulated_ms BIGINT NOT NULL DEFAULT 0; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS time_started_at TIMESTAMPTZ; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ; +-- Backfill: running projects count from their creation; seed last activity. +UPDATE projects SET time_started_at = created_at WHERE time_running AND time_started_at IS NULL; +UPDATE projects SET last_activity_at = created_at WHERE last_activity_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Folders to consider for verification. When a project has ANY rows here, only +-- videos in those folders are shown/counted; empty = consider all folders. +-- Admins add/modify/delete the set. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS project_folders ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + folder TEXT NOT NULL, -- directory part of rel_path ('' = project root) + PRIMARY KEY (project_id, folder) +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..455f7a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,98 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: central + POSTGRES_PASSWORD: central + POSTGRES_DB: central + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + ports: + - "5433:5432" # host 5433 → container 5432 (avoid clashing local pg) + healthcheck: + test: ["CMD-SHELL", "pg_isready -U central -d central"] + interval: 5s + timeout: 3s + retries: 10 + + server: + build: + context: . + dockerfile: server/Dockerfile + environment: + DATABASE_URL: postgres://central:central@db:5432/central + BIND_ADDR: 0.0.0.0:8080 + # Folder the ingest scans. Locally this is ./sample-data (mounted below); + # on the VM, mount the Synology share here instead and point INGEST_DIR at it. + INGEST_DIR: /data + # Phase 3: master token that grants admin (provision users, create projects). + # CHANGE THIS in production — it is a break-glass credential. + ADMIN_TOKEN: 198eb78d0687cca88e22f76759f0154bf85c3f926f225c6c7c84538a57c25ef7 + # Claim lease length in seconds (auto-released after this if not heartbeated). + LEASE_SECS: ${LEASE_SECS:-900} + # NAS host for `nfs`-kind projects. The IP stays here (server-side); users only ever enter the + # export path (e.g. /volume4/Saudi_Video_Sync). Leave empty to disable in-app NFS mounting. + NFS_HOST: ${NFS_HOST:-192.168.1.199} + NFS_OPTS: ${NFS_OPTS:-nfsvers=4,ro,soft,timeo=30,retrans=2,retry=0} + volumes: + - ./sample-data:/data:ro + # Needed for the server to mount `nfs`-kind shares on demand. Not required if you only use + # `local`-kind projects (plain paths or compose-managed NFS volumes). If mount.nfs still fails + # on your host, replace these two with `privileged: true`. + cap_add: + - SYS_ADMIN + security_opt: + - apparmor:unconfined + depends_on: + db: + condition: service_healthy + ports: + - "8080:8080" + + dashboard: + build: ./dashboard + environment: + # The dashboard calls the API through nginx's /api proxy, so no host needed. + VITE_API_BASE: /api + depends_on: + - server + ports: + - "8081:80" + + # Nightly pg_dump → NAS, with automatic daily/weekly/monthly pruning. + # Writes gzipped SQL dumps to /backups (an NFS mount of the NAS folder below). + db-backup: + image: prodrigestivill/postgres-backup-local:16 + restart: unless-stopped + environment: + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_DB: central + POSTGRES_USER: central + POSTGRES_PASSWORD: central + # Schedule is evaluated in this timezone (IST). 02:00 IST daily (off-peak). + TZ: Asia/Kolkata + SCHEDULE: "0 2 * * *" + # Tiered retention — tune to taste (DB is tiny, so generous is cheap). + BACKUP_KEEP_DAYS: "30" + BACKUP_KEEP_WEEKS: "8" + BACKUP_KEEP_MONTHS: "6" + # Healthcheck window so a missed run is visible (`docker inspect` health). + HEALTHCHECK_PORT: "8080" + volumes: + - nasbackups:/backups + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: + # NFS mount of the NAS backup folder. The folder must already exist on the NAS and + # allow rw NFS from this Docker host. Host needs an NFS client (nfs-common). + nasbackups: + driver: local + driver_opts: + type: nfs + o: "addr=${NFS_HOST:-192.168.1.199},nfsvers=4,rw,soft,timeo=30,retrans=2" + device: ":/volume4/Saudi_Video_Sync/Verification_Dashboard_Backup" diff --git a/docs/er-diagram.md b/docs/er-diagram.md new file mode 100644 index 0000000..8eae2ee --- /dev/null +++ b/docs/er-diagram.md @@ -0,0 +1,169 @@ +# Central DB — ER diagram + +Schema for the `central/` verification dashboard (Postgres). Source of truth: +[`central/db/init.sql`](../db/init.sql) (re-run idempotently on every server boot). +This diagram was generated from the **live** database and verified against it. + +> Render: GitHub renders the Mermaid block below directly; in VS Code use the +> *Markdown Preview Mermaid* extension (or any Mermaid viewer). + +## Hierarchy at a glance + +``` +clients ──1:N──▶ projects ──1:N──▶ videos ──1:N──▶ annotations + ├──1:N──▶ video_remarks (remark thread) + └──1:N──▶ video_events (claim/push log) + +users — provisioned workers/admins (token auth) +audit_events — org-level action log + (both link to the hierarchy only "softly", by username string — no FK) +``` + +## Entity–relationship diagram + +```mermaid +erDiagram + clients ||--o{ projects : "owns" + projects ||--o{ videos : "contains" + videos ||--o{ annotations : "has" + videos ||--o{ video_remarks : "thread" + videos ||--o{ video_events : "logs" + + clients { + int id PK + text name UK "unique client name" + timestamptz created_at + } + + projects { + int id PK + int client_id FK "→ clients.id (CASCADE); nullable" + text name "UK(client_id, name)" + text source_path "NAS export path only — host lives in NFS_HOST env" + text source_kind "'local' | 'nfs'" + timestamptz created_at + } + + videos { + int id PK "= serverVideoId on the desktop" + int project_id FK "→ projects.id (CASCADE)" + text file_name + text rel_path "UK(project_id, rel_path); keeps subfolders" + bool has_json "sibling export-JSON exists" + int width + int height + float8 fps + int frame_count + int annotation_count + bigint annotation_time_ms + text primary_annotator + text status "pending | annotated | verified" + timestamptz annotated_at "max(annotation.created_at)" + jsonb raw_json "full imported export doc (Phase-3 pull)" + timestamptz ingested_at + text claimed_by "claim dimension — orthogonal to status" + timestamptz claimed_at + timestamptz lease_expires_at + text completed_by "set on push (verify pass)" + timestamptz completed_at + bigint verify_time_ms + } + + annotations { + int id PK + int video_id FK "→ videos.id (CASCADE)" + int frame_number "0-indexed" + text label + text side + text shape_type "bbox | polygon" + jsonb vertices + text annotated_by "username (soft link to users)" + text review_status "default 'none'" + text remark "per-annotation note (NOT the thread)" + text subclass + text created_at + } + + video_remarks { + int id PK + int video_id FK "→ videos.id (CASCADE)" + text username "author (soft link to users)" + text body + timestamptz created_at + } + + video_events { + int id PK + int video_id FK "→ videos.id (CASCADE)" + text username + text event "claim | release | push | auto_release | reopen" + timestamptz at + jsonb meta + } + + users { + int id PK + text username UK + text display_name + text token_hash "SHA-256 of issued token (never clear)" + text role "worker | admin" + bool active + timestamptz created_at + } + + audit_events { + int id PK + timestamptz at + text username "actor" + text action "client_created | project_created | project_source_changed | synced | user_created | user_token_reset" + jsonb detail + } +``` + +## Relationships + +| Parent | Child | Cardinality | On delete | Constraint | +|---|---|---|---|---| +| `clients.id` | `projects.client_id` | 1 : N | CASCADE | FK | +| `projects.id` | `videos.project_id` | 1 : N | CASCADE | FK | +| `videos.id` | `annotations.video_id` | 1 : N | CASCADE | FK | +| `videos.id` | `video_remarks.video_id` | 1 : N | CASCADE | FK | +| `videos.id` | `video_events.video_id` | 1 : N | CASCADE | FK | + +**Soft links (by `username` string — no FK):** `users.username` is referenced as text by +`annotations.annotated_by`, `videos.claimed_by` / `completed_by` / `primary_annotator`, +`video_remarks.username`, `video_events.username`, and `audit_events.username`. These are +intentionally not foreign keys so historical attribution survives a user being removed. + +## Unique constraints & key indexes + +- **Unique:** `clients(name)`, `projects(client_id, name)`, `videos(project_id, rel_path)`, `users(username)`. +- **Indexes:** `idx_videos_project`, `idx_videos_status(project_id,status)`, `idx_videos_claim(project_id,claimed_by,lease_expires_at)`, + `idx_annotations_video`, `idx_annotations_user`, `idx_users_token`, `idx_video_remarks_video(video_id,created_at)`, + `idx_events_video`, `idx_events_at`, `idx_audit_at`, `idx_projects_client`. + +## Notes + +- **Two orthogonal dimensions on `videos`:** annotation `status` (`pending`/`annotated`/`verified`) + vs. the claim/lease columns (`claimed_by` + `lease_expires_at`). Claimable = `status IN ('pending','annotated')` + and unclaimed-or-lease-expired; push → `verified` + `completed_by`/`verify_time_ms`. +- **A range annotation** in the export doc expands to **1–2 rows** here (start frame, and end frame if present). +- The NAS host/IP is never persisted — `projects.source_path` holds only the export path; the host lives in the `NFS_HOST` server env. +- Everything cascades from `clients`: deleting a client removes its projects → videos → annotations/remarks/events. + +--- + +### Regenerate / verify against the live DB + +```bash +# columns +docker exec central-db-1 psql -U central -d central -c "\d+ videos" +# foreign keys +docker exec central-db-1 psql -U central -d central -Atc " +SELECT tc.table_name, kcu.column_name, ccu.table_name, rc.delete_rule +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu USING (constraint_name) +JOIN information_schema.constraint_column_usage ccu USING (constraint_name) +JOIN information_schema.referential_constraints rc USING (constraint_name) +WHERE tc.constraint_type='FOREIGN KEY';" +``` diff --git a/sample-data/2026_0508_092422_F.MP4 b/sample-data/2026_0508_092422_F.MP4 new file mode 100644 index 0000000..e69de29 diff --git a/sample-data/2026_0508_092422_F.MP4_annotations.json b/sample-data/2026_0508_092422_F.MP4_annotations.json new file mode 100644 index 0000000..a097e5c --- /dev/null +++ b/sample-data/2026_0508_092422_F.MP4_annotations.json @@ -0,0 +1,33 @@ +{ + "video": { + "file_name": "2026_0508_092422_F.MP4", + "width": 1920, + "height": 1080, + "fps": 30.0, + "frame_count": 18000, + "annotation_time_ms": 1804807 + }, + "fixed_annotations": [ + { + "label": "CCTV", "side": "Right", "frame_number": 114, "type": "bbox", + "vertices": [{"x":0.530,"y":0.306},{"x":0.768,"y":0.306},{"x":0.768,"y":0.549},{"x":0.530,"y":0.549}], + "annotated_by": "shubham", "created_at": "2026-06-10 10:22:40" + }, + { + "label": "Street_Light", "side": "Right", "frame_number": 128, "type": "bbox", + "vertices": [{"x":0.541,"y":0.117},{"x":0.668,"y":0.117},{"x":0.668,"y":0.487},{"x":0.541,"y":0.487}], + "annotated_by": "shubham", "created_at": "2026-06-10 10:22:49" + }, + { + "label": "Mandatory_Board", "side": "Right", "frame_number": 136, "type": "bbox", + "vertices": [{"x":0.670,"y":0.563},{"x":0.729,"y":0.563},{"x":0.729,"y":0.675},{"x":0.670,"y":0.675}], + "annotated_by": "shubham", "created_at": "2026-06-10 10:23:08" + }, + { + "label": "Delineator", "side": "Right", "frame_number": 12238, "type": "bbox", + "vertices": [{"x":0.795,"y":0.644},{"x":0.880,"y":0.644},{"x":0.880,"y":0.770},{"x":0.795,"y":0.770}], + "annotated_by": "shubham", "created_at": "2026-06-09 10:28:47" + } + ], + "range_annotations": [] +} diff --git a/sample-data/2026_0508_101500_R.MP4 b/sample-data/2026_0508_101500_R.MP4 new file mode 100644 index 0000000..e69de29 diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..15b1733 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "central-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "hex", + "rand", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "walkdir", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..b4c18c0 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "central-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +walkdir = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +# Phase 3: token hashing (sha256) + random token generation + file-stream download. +sha2 = "0.10" +rand = "0.8" +hex = "0.4" +tokio-util = { version = "0.7", features = ["io"] } diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..bb7d697 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,18 @@ +# Build context is the `central/` dir (see docker-compose) so we can copy both +# server/ and db/ — the server embeds db/init.sql via include_str!("../../db/init.sql"). +FROM rust:1-bookworm AS build +WORKDIR /build +COPY server/Cargo.toml server/Cargo.toml +COPY server/src server/src +COPY db db +WORKDIR /build/server +RUN cargo build --release + +FROM debian:bookworm-slim +# nfs-common provides mount.nfs for `nfs`-kind projects (mounted on demand at sync/download time). +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates nfs-common \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /build/server/target/release/central-server /usr/local/bin/central-server +ENV BIND_ADDR=0.0.0.0:8080 +EXPOSE 8080 +CMD ["central-server"] diff --git a/server/src/admin.rs b/server/src/admin.rs new file mode 100644 index 0000000..9ce216a --- /dev/null +++ b/server/src/admin.rs @@ -0,0 +1,435 @@ +//! Phase 3 admin API — provisioning. Gated by `AuthUser::require_admin()`, which is +//! satisfied by a user with role 'admin' or by the master `ADMIN_TOKEN`. + +use crate::auth::{gen_token, sha256_hex, AuthUser}; +use crate::models::*; +use crate::{err, AppState, ApiResult}; +use axum::{ + extract::{Path as AxPath, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; + +fn is_unique_violation(e: &sqlx::Error) -> bool { + e.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false) +} + +/// Record an org-level audit event (best-effort: log on failure, never block the action). +pub async fn audit(db: &sqlx::PgPool, username: &str, action: &str, detail: Value) { + if let Err(e) = + sqlx::query("INSERT INTO audit_events (username, action, detail) VALUES ($1,$2,$3)") + .bind(username) + .bind(action) + .bind(detail) + .execute(db) + .await + { + tracing::warn!("failed to record audit '{action}' by '{username}': {e}"); + } +} + +/// `POST /api/users` — provision a worker/admin. Generates the token, stores only its +/// hash, and returns the clear token **once** (it can never be retrieved again). +pub async fn create_user( + State(s): State, + user: AuthUser, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + if req.username.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "username is required".into())); + } + // Only two roles exist: 'admin' and 'ml_support' (anything else normalizes to ml_support). + let role = if req.role == "admin" { "admin" } else { "ml_support" }; + let display_name = if req.display_name.trim().is_empty() { + req.username.clone() + } else { + req.display_name.clone() + }; + let token = gen_token(); + let token_hash = sha256_hex(&token); + + let res = sqlx::query( + "INSERT INTO users (username, display_name, token_hash, role) VALUES ($1,$2,$3,$4)", + ) + .bind(&req.username) + .bind(&display_name) + .bind(&token_hash) + .bind(role) + .execute(&s.db) + .await; + + match res { + Ok(_) => { + audit(&s.db, &user.username, "user_created", + json!({ "username": req.username, "role": role })).await; + Ok(Json(NewUserResponse { + username: req.username, + display_name, + role: role.into(), + token, + })) + } + Err(e) if is_unique_violation(&e) => { + Err((StatusCode::CONFLICT, "username already exists".into())) + } + Err(e) => Err(err(e)), + } +} + +/// `POST /api/users/:id/token` — regenerate a user's access token (admin only), e.g. +/// when they forget it. The old token stops working immediately; the new clear token +/// is returned **once**. The master `ADMIN_TOKEN` env is unaffected (it's not a row). +pub async fn rotate_token( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + user.require_admin()?; + let token = gen_token(); + let token_hash = sha256_hex(&token); + let row: Option<(String, String, String)> = sqlx::query_as( + "UPDATE users SET token_hash=$2 WHERE id=$1 RETURNING username, display_name, role", + ) + .bind(id) + .bind(&token_hash) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some((username, display_name, role)) = row else { + return Err((StatusCode::NOT_FOUND, "user not found".into())); + }; + audit(&s.db, &user.username, "user_token_reset", + json!({ "user_id": id, "username": username })).await; + Ok(Json(NewUserResponse { username, display_name, role, token })) +} + +/// `POST /api/users/:id/update {display_name, role}` — edit a user's display name + +/// role (admin only). Empty display_name falls back to the username. Tokens are not +/// affected (they're hashed; use rotate_token to issue a new one). +pub async fn update_user( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let username: Option = sqlx::query_scalar("SELECT username FROM users WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some(username) = username else { + return Err((StatusCode::NOT_FOUND, "user not found".into())); + }; + let role = if req.role == "admin" { "admin" } else { "ml_support" }; + let display_name = if req.display_name.trim().is_empty() { + username.clone() + } else { + req.display_name.trim().to_string() + }; + sqlx::query("UPDATE users SET display_name=$2, role=$3 WHERE id=$1") + .bind(id) + .bind(&display_name) + .bind(role) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "user_updated", + json!({ "user_id": id, "username": username, "display_name": display_name, "role": role })).await; + Ok(Json(json!({ "updated": true, "display_name": display_name, "role": role }))) +} + +/// `GET /api/users` — list provisioned users (never exposes tokens). +pub async fn list_users(State(s): State, user: AuthUser) -> ApiResult> { + user.require_admin()?; + let rows = sqlx::query_as::<_, UserRow>( + "SELECT id, username, display_name, role, active, created_at FROM users ORDER BY id", + ) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/clients` — create a client. Unique on name. +pub async fn create_client( + State(s): State, + user: AuthUser, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + if req.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "client name is required".into())); + } + let res: Result<(i32, chrono::DateTime), sqlx::Error> = sqlx::query_as( + "INSERT INTO clients (name) VALUES ($1) RETURNING id, created_at", + ) + .bind(&req.name) + .fetch_one(&s.db) + .await; + + match res { + Ok((id, created_at)) => { + audit(&s.db, &user.username, "client_created", json!({ "client_id": id, "name": req.name })).await; + Ok(Json(ClientRow { + id, + name: req.name, + created_at, + project_count: 0, + })) + } + Err(e) if is_unique_violation(&e) => { + Err((StatusCode::CONFLICT, "client name already exists".into())) + } + Err(e) => Err(err(e)), + } +} + +fn norm_kind(k: &str) -> &'static str { + if k == "nfs" { "nfs" } else { "local" } +} + +/// `POST /api/projects` — create (or repoint) a project under a client. Allowed for **any +/// authenticated user** (collaborators add projects); idempotent on `(client_id, name)`. +pub async fn create_project( + State(s): State, + user: AuthUser, + Json(req): Json, +) -> ApiResult { + if req.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "project name is required".into())); + } + if req.source_path.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "source path is required".into())); + } + let kind = norm_kind(&req.source_kind); + let client_exists: Option = sqlx::query_scalar("SELECT id FROM clients WHERE id=$1") + .bind(req.client_id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if client_exists.is_none() { + return Err((StatusCode::NOT_FOUND, "client not found".into())); + } + let id: i32 = sqlx::query_scalar( + r#"INSERT INTO projects (name, source_path, source_kind, client_id) VALUES ($1,$2,$3,$4) + ON CONFLICT (client_id, name) + DO UPDATE SET source_path=EXCLUDED.source_path, source_kind=EXCLUDED.source_kind + RETURNING id"#, + ) + .bind(&req.name) + .bind(&req.source_path) + .bind(kind) + .bind(req.client_id) + .fetch_one(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "project_created", + json!({ "project_id": id, "client_id": req.client_id, "name": req.name, + "source_path": req.source_path, "source_kind": kind })).await; + Ok(Json(json!({ + "id": id, "client_id": req.client_id, "name": req.name, + "source_path": req.source_path, "source_kind": kind + }))) +} + +/// `POST /api/projects/:id/source` — change a project's sync directory (any authenticated user). +pub async fn update_project_source( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + if req.source_path.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "source path is required".into())); + } + let kind = norm_kind(&req.source_kind); + let updated: Option = sqlx::query_scalar( + "UPDATE projects SET source_path=$2, source_kind=$3 WHERE id=$1 RETURNING id", + ) + .bind(id) + .bind(&req.source_path) + .bind(kind) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "project not found".into())); + } + audit(&s.db, &user.username, "project_source_changed", + json!({ "project_id": id, "source_path": req.source_path, "source_kind": kind })).await; + Ok(Json(json!({ "id": id, "source_path": req.source_path, "source_kind": kind }))) +} + +/// `DELETE /api/projects/:id` — delete a project and everything under it (videos, +/// annotations, events, remarks all cascade). Admin only. +pub async fn delete_project( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + user.require_admin()?; + let name: Option = sqlx::query_scalar("SELECT name FROM projects WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some(name) = name else { + return Err((StatusCode::NOT_FOUND, "project not found".into())); + }; + sqlx::query("DELETE FROM projects WHERE id=$1") + .bind(id) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "project_deleted", json!({ "project_id": id, "name": name })).await; + Ok(Json(json!({ "deleted": true, "id": id }))) +} + +/// `DELETE /api/clients/:id` — delete a client and ALL its projects + their data +/// (cascades through projects → videos → annotations). Admin only. The dashboard +/// re-affirms (type-the-name) before calling this. +pub async fn delete_client( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + user.require_admin()?; + let name: Option = sqlx::query_scalar("SELECT name FROM clients WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some(name) = name else { + return Err((StatusCode::NOT_FOUND, "client not found".into())); + }; + let project_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE client_id=$1") + .bind(id) + .fetch_one(&s.db) + .await + .map_err(err)?; + sqlx::query("DELETE FROM clients WHERE id=$1") + .bind(id) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "client_deleted", + json!({ "client_id": id, "name": name, "projects_removed": project_count })).await; + Ok(Json(json!({ "deleted": true, "id": id, "projects_removed": project_count }))) +} + +/// `GET /api/projects/:id/members` — users picked for a project. Admin or member. +pub async fn list_members( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult> { + crate::require_project_access(&s, &user, id).await?; + let rows = sqlx::query_as::<_, MemberRow>( + r#"SELECT m.username, + COALESCE(u.display_name, m.username) AS display_name, + COALESCE(u.role, '') AS role, + m.added_at + FROM project_members m + LEFT JOIN users u ON u.username = m.username + WHERE m.project_id=$1 + ORDER BY m.username"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/projects/:id/members {username}` — pick a user for a project. Admin only. +pub async fn add_member( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let username = req.username.trim().to_string(); + if username.is_empty() { + return Err((StatusCode::BAD_REQUEST, "username is required".into())); + } + let exists: Option = sqlx::query_scalar("SELECT id FROM users WHERE username=$1 AND active") + .bind(&username) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if exists.is_none() { + return Err((StatusCode::BAD_REQUEST, format!("unknown user '{username}'"))); + } + sqlx::query( + "INSERT INTO project_members (project_id, username) VALUES ($1,$2) ON CONFLICT DO NOTHING", + ) + .bind(id) + .bind(&username) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "project_member_added", + json!({ "project_id": id, "username": username })).await; + Ok(Json(json!({ "added": true, "username": username }))) +} + +/// `DELETE /api/projects/:id/members/:username` — remove a user from a project (admin +/// only). Also clears any of that user's video assignments within the project. +pub async fn remove_member( + State(s): State, + user: AuthUser, + AxPath((id, username)): AxPath<(i32, String)>, +) -> ApiResult { + user.require_admin()?; + sqlx::query("DELETE FROM project_members WHERE project_id=$1 AND username=$2") + .bind(id) + .bind(&username) + .execute(&s.db) + .await + .map_err(err)?; + // Unassign their videos in this project so nothing stays assigned to a non-member. + sqlx::query("UPDATE videos SET assigned_to=NULL WHERE project_id=$1 AND assigned_to=$2") + .bind(id) + .bind(&username) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "project_member_removed", + json!({ "project_id": id, "username": username })).await; + Ok(Json(json!({ "removed": true, "username": username }))) +} + +/// `DELETE /api/users/:id` — remove a collaborator (admin or ml_support). Admin only. +/// You cannot delete the account you're signed in as (use a different admin / the +/// master token). Annotations/events keep their text `annotated_by`/`username` +/// attribution (those are not FKs), so history is preserved. +pub async fn delete_user( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + user.require_admin()?; + let target: Option = sqlx::query_scalar("SELECT username FROM users WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some(username) = target else { + return Err((StatusCode::NOT_FOUND, "user not found".into())); + }; + if username == user.username { + return Err((StatusCode::BAD_REQUEST, "you cannot delete your own account".into())); + } + sqlx::query("DELETE FROM users WHERE id=$1") + .bind(id) + .execute(&s.db) + .await + .map_err(err)?; + audit(&s.db, &user.username, "user_deleted", json!({ "user_id": id, "username": username })).await; + Ok(Json(json!({ "deleted": true, "id": id, "username": username }))) +} diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..eecb056 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,93 @@ +//! Phase 3 token auth. Workers/admins authenticate with `Authorization: Bearer `. +//! Tokens are random 32-byte hex strings; only their SHA-256 hash is stored +//! (`users.token_hash`). A master `ADMIN_TOKEN` env var maps to a synthetic admin +//! identity so the first real user can be provisioned (bootstrap). + +use crate::AppState; +use axum::{ + async_trait, + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts, StatusCode}, +}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +/// Lowercase hex SHA-256 of `s` — used to hash tokens before storage/lookup. +pub fn sha256_hex(s: &str) -> String { + let mut h = Sha256::new(); + h.update(s.as_bytes()); + hex::encode(h.finalize()) +} + +/// Generate a fresh 256-bit token as a 64-char hex string. +pub fn gen_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// The authenticated caller. Use as a handler argument to require a valid token. +pub struct AuthUser { + pub username: String, + pub role: String, +} + +impl AuthUser { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } + /// 403 unless this caller is an admin. + pub fn require_admin(&self) -> Result<(), (StatusCode, String)> { + if self.is_admin() { + Ok(()) + } else { + Err((StatusCode::FORBIDDEN, "admin only".into())) + } + } +} + +fn bearer(parts: &Parts) -> Option { + let raw = parts.headers.get(AUTHORIZATION)?.to_str().ok()?; + raw.strip_prefix("Bearer ") + .or_else(|| raw.strip_prefix("bearer ")) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +/// Resolve a raw token to an `AuthUser`, or `None` if it's empty/unknown/inactive. +/// Used both by the `FromRequestParts` extractor (header auth) and by endpoints that +/// must accept a `?token=` query param (file downloads via ``, which can't +/// set an Authorization header). +pub async fn validate_token(state: &AppState, token: &str) -> Option { + let token = token.trim(); + if token.is_empty() { + return None; + } + // Master admin token (bootstrap / break-glass). + if !state.admin_token.is_empty() && token == state.admin_token { + return Some(AuthUser { username: "admin".into(), role: "admin".into() }); + } + let hash = sha256_hex(token); + sqlx::query_as::<_, (String, String)>( + "SELECT username, role FROM users WHERE token_hash=$1 AND active", + ) + .bind(&hash) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .map(|(username, role)| AuthUser { username, role }) +} + +#[async_trait] +impl FromRequestParts for AuthUser { + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = bearer(parts) + .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token".to_string()))?; + validate_token(state, &token) + .await + .ok_or((StatusCode::UNAUTHORIZED, "invalid or inactive token".into())) + } +} diff --git a/server/src/ingest.rs b/server/src/ingest.rs new file mode 100644 index 0000000..2423541 --- /dev/null +++ b/server/src/ingest.rs @@ -0,0 +1,254 @@ +use crate::models::ExportDoc; +use chrono::{DateTime, NaiveDateTime, Utc}; +use sqlx::PgPool; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +const VIDEO_EXTS: &[&str] = &["mp4", "mkv", "avi", "mov", "m4v", "webm"]; + +fn is_video(p: &Path) -> bool { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +/// Find the sibling export-JSON for a video. Supports `_annotations.json` +/// (video-annotator's default export name), `.json`, and `.json`. +fn sibling_json(video: &Path) -> Option { + let dir = video.parent()?; + let file_name = video.file_name()?.to_str()?; + let stem = video.file_stem()?.to_str()?; + let candidates = [ + format!("{file_name}_annotations.json"), + format!("{file_name}.json"), + format!("{stem}.json"), + format!("{stem}_annotations.json"), + ]; + for c in candidates { + let p = dir.join(c); + if p.is_file() { + return Some(p); + } + } + None +} + +fn parse_ts(s: &str) -> Option> { + if s.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|n| n.and_utc()) +} + +/// Scan `dir`, upsert every video (and its annotations) into the project. +/// Idempotent: re-running updates rows; never downgrades a 'verified' video. +pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow::Result { + if !dir.is_dir() { + anyhow::bail!("ingest dir not found: {}", dir.display()); + } + let mut count = 0usize; + + for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !entry.file_type().is_file() || !is_video(path) { + continue; + } + let rel_path = path + .strip_prefix(dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Never clobber a verified push (Phase 3): a worker's pushed annotations + + // completer/verify-time are authoritative, so skip re-ingesting this video. + let existing_status: Option = + sqlx::query_scalar("SELECT status FROM videos WHERE project_id=$1 AND rel_path=$2") + .bind(project_id) + .bind(&rel_path) + .fetch_optional(db) + .await?; + if existing_status.as_deref() == Some("verified") { + tracing::debug!("skip re-ingest of verified video: {rel_path}"); + continue; + } + + let json_path = sibling_json(path); + let has_json = json_path.is_some(); + + // Parse the sibling JSON if present. + let doc: Option = json_path.as_ref().and_then(|jp| { + std::fs::read_to_string(jp) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + }); + + let (width, height, fps, frame_count, time_ms) = match doc.as_ref().and_then(|d| d.video.as_ref()) { + Some(v) => ( + v.width.map(|x| x as i32), + v.height.map(|x| x as i32), + v.fps, + v.frame_count.map(|x| x as i32), + v.annotation_time_ms, + ), + None => (None, None, None, None, 0i64), + }; + + // Aggregate annotation_count, primary annotator, annotated_at. + let mut ann_count: i32 = 0; + let mut by_freq: HashMap = HashMap::new(); + let mut latest: Option> = None; + if let Some(d) = doc.as_ref() { + for a in &d.fixed_annotations { + ann_count += 1; + if !a.annotated_by.is_empty() { + *by_freq.entry(a.annotated_by.clone()).or_default() += 1; + } + if let Some(t) = parse_ts(&a.created_at) { + latest = Some(latest.map_or(t, |l| l.max(t))); + } + } + for a in &d.range_annotations { + ann_count += 1; + if !a.annotated_by.is_empty() { + *by_freq.entry(a.annotated_by.clone()).or_default() += 1; + } + if let Some(t) = parse_ts(&a.created_at) { + latest = Some(latest.map_or(t, |l| l.max(t))); + } + } + } + let primary_annotator = by_freq + .into_iter() + .max_by_key(|(_, c)| *c) + .map(|(u, _)| u) + .unwrap_or_default(); + let status = if has_json && ann_count > 0 { "annotated" } else { "pending" }; + let raw_json: Option = json_path.as_ref().and_then(|jp| { + std::fs::read_to_string(jp).ok().and_then(|t| serde_json::from_str(&t).ok()) + }); + + // Upsert the video. Keep 'verified' status sticky (Phase 3 pushes). + let video_id: i32 = sqlx::query_scalar( + r#" + INSERT INTO videos + (project_id, file_name, rel_path, has_json, width, height, fps, frame_count, + annotation_count, annotation_time_ms, primary_annotator, status, annotated_at, raw_json, + imported_count) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (project_id, rel_path) DO UPDATE SET + file_name=EXCLUDED.file_name, + has_json=EXCLUDED.has_json, + width=EXCLUDED.width, height=EXCLUDED.height, fps=EXCLUDED.fps, + frame_count=EXCLUDED.frame_count, + annotation_count=EXCLUDED.annotation_count, + annotation_time_ms=EXCLUDED.annotation_time_ms, + primary_annotator=EXCLUDED.primary_annotator, + status = CASE WHEN videos.status='verified' THEN videos.status ELSE EXCLUDED.status END, + annotated_at=EXCLUDED.annotated_at, + raw_json=EXCLUDED.raw_json, + -- imported baseline = the NAS-ingested count (skipped for verified videos + -- via the early continue above, so a pushed pass's baseline is preserved). + imported_count=EXCLUDED.imported_count, + ingested_at=now() + RETURNING id + "#, + ) + .bind(project_id) + .bind(&file_name) + .bind(&rel_path) + .bind(has_json) + .bind(width) + .bind(height) + .bind(fps) + .bind(frame_count) + .bind(ann_count) + .bind(time_ms) + .bind(&primary_annotator) + .bind(status) + .bind(latest) + .bind(raw_json) + .bind(ann_count) + .fetch_one(db) + .await?; + + // Replace detailed annotation rows for this video. + if let Some(d) = doc.as_ref() { + replace_annotations(db, video_id, d).await?; + } else { + sqlx::query("DELETE FROM annotations WHERE video_id=$1") + .bind(video_id) + .execute(db) + .await?; + } + count += 1; + } + Ok(count) +} + +/// Replace all annotation rows for a video with the contents of an export doc. +/// Shared by ingest (NAS JSON) and the Phase 3 worker push. Returns the row count +/// written (each range annotation contributes 1–2 rows: start, and end if present). +pub async fn replace_annotations( + db: &PgPool, + video_id: i32, + d: &ExportDoc, +) -> anyhow::Result { + sqlx::query("DELETE FROM annotations WHERE video_id=$1") + .bind(video_id) + .execute(db) + .await?; + let mut written = 0i32; + for a in &d.fixed_annotations { + insert_ann(db, video_id, a.frame_number, &a.label, &a.side, &a.shape_type, + &a.vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?; + written += 1; + } + for a in &d.range_annotations { + insert_ann(db, video_id, a.start_frame, &a.label, &a.side, &a.shape_type, + &a.start_vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?; + written += 1; + if let (Some(ef), Some(ev)) = (a.end_frame, a.end_vertices.as_ref()) { + insert_ann(db, video_id, ef, &a.label, &a.side, &a.shape_type, + ev, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?; + written += 1; + } + } + Ok(written) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_ann( + db: &PgPool, video_id: i32, frame: i64, label: &str, side: &str, shape: &str, + vertices: &serde_json::Value, by: &str, review: &str, remark: &str, subclass: &str, created_at: &str, +) -> anyhow::Result<()> { + sqlx::query( + r#"INSERT INTO annotations + (video_id, frame_number, label, side, shape_type, vertices, annotated_by, review_status, remark, subclass, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#, + ) + .bind(video_id) + .bind(frame as i32) + .bind(label) + .bind(side) + .bind(if shape.is_empty() { "bbox" } else { shape }) + .bind(vertices) + .bind(by) + .bind(if review.is_empty() { "none" } else { review }) + .bind(remark) + .bind(subclass) + .bind(created_at) + .execute(db) + .await?; + Ok(()) +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..eea90b0 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,969 @@ +mod admin; +mod auth; +mod ingest; +mod models; +mod nfs; +mod worker; + +use auth::AuthUser; +use axum::{ + body::Body, + extract::{Path as AxPath, Query, State}, + http::{header, StatusCode}, + response::Response, + routing::{delete, get, post}, + Json, Router, +}; +use models::*; +use serde_json::json; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; +use tower_http::cors::CorsLayer; + +const SCHEMA: &str = include_str!("../../db/init.sql"); + +/// Max rows kept in each append-only log (audit_events + non-push video_events). +/// Trimmed nightly so the DB doesn't grow unbounded. 'push' video_events are kept +/// regardless (they're load-bearing: verify_count / verifiers derive from them). +const MAX_LOG_ROWS: i64 = 5000; + +/// Window (days) for the "verified per day" verification-pace metric. +const RECENT_WINDOW_DAYS: i64 = 7; + +/// SQL fragment: restrict to videos in a "considered" folder, or all folders when +/// none are configured for the project. References the bound project_id as `$1` and +/// the unaliased `videos` table (so `rel_path` resolves). Append to a query whose +/// first bind is the project id. +const FOLDER_FILTER: &str = " AND (NOT EXISTS (SELECT 1 FROM project_folders pf WHERE pf.project_id = $1) \ + OR (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path, '/[^/]*$', '') ELSE '' END) \ + IN (SELECT folder FROM project_folders WHERE project_id = $1)) "; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub ingest_dir: PathBuf, + /// Master token granting admin role (bootstrap / break-glass). + pub admin_token: String, + /// Claim lease length in seconds. + pub lease_secs: i32, + /// NAS host for `nfs`-kind projects (e.g. 192.168.1.199). Server-side only — never returned. + pub nfs_host: String, + /// Mount options for `nfs`-kind projects. + pub nfs_opts: String, +} + +pub type ApiResult = Result, (StatusCode, String)>; +pub fn err(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into())) + .init(); + + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://central:central@localhost:5433/central".into()); + let bind = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let ingest_dir = PathBuf::from(std::env::var("INGEST_DIR").unwrap_or_else(|_| "./sample-data".into())); + let project_name = std::env::var("PROJECT_NAME").unwrap_or_else(|_| "default".into()); + let admin_token = std::env::var("ADMIN_TOKEN").unwrap_or_else(|_| "dev-admin-token".into()); + if admin_token == "dev-admin-token" { + tracing::warn!("ADMIN_TOKEN is the insecure default 'dev-admin-token' — set it in production"); + } + let lease_secs: i32 = std::env::var("LEASE_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(900); + let nfs_host = std::env::var("NFS_HOST").unwrap_or_default(); + let nfs_opts = std::env::var("NFS_OPTS") + .unwrap_or_else(|_| "nfsvers=4,ro,soft,timeo=30,retrans=2,retry=0".into()); + + // Connect with retry — the DB container may still be starting. + let db = connect_with_retry(&db_url, 30).await?; + sqlx::raw_sql(SCHEMA).execute(&db).await?; + + // On a fresh DB only, seed a 'default' client/project at INGEST_DIR for local dev. + // Once real clients exist, we leave the data alone (no 'default' reappearing). + let client_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM clients") + .fetch_one(&db) + .await?; + if client_count == 0 { + let default_client_id: i32 = + sqlx::query_scalar("INSERT INTO clients (name) VALUES ('default') RETURNING id") + .fetch_one(&db) + .await?; + let project_id: i32 = sqlx::query_scalar( + r#"INSERT INTO projects (name, source_path, client_id) VALUES ($1,$2,$3) + ON CONFLICT (client_id, name) DO UPDATE SET source_path=EXCLUDED.source_path + RETURNING id"#, + ) + .bind(&project_name) + .bind(ingest_dir.to_string_lossy().to_string()) + .bind(default_client_id) + .fetch_one(&db) + .await?; + match ingest::ingest_project(&db, project_id, &ingest_dir).await { + Ok(n) => tracing::info!("seeded default project; startup ingest: {n} videos from {}", ingest_dir.display()), + Err(e) => tracing::warn!("startup ingest failed (non-fatal): {e}"), + } + } else { + tracing::info!("{client_count} client(s) present; skipping default seed"); + } + + let state = AppState { db, ingest_dir, admin_token, lease_secs, nfs_host, nfs_opts }; + + // Phase 3: background task that auto-releases expired claims back to the pool. + { + let db = state.db.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(60)); + loop { + tick.tick().await; + match release_expired_leases(&db).await { + Ok(n) if n > 0 => tracing::info!("auto-released {n} expired claim(s)"), + Ok(_) => {} + Err(e) => tracing::warn!("lease sweep failed: {e}"), + } + match auto_stop_idle_timers(&db).await { + Ok(n) if n > 0 => tracing::info!("auto-stopped {n} idle project timer(s) (72h)"), + Ok(_) => {} + Err(e) => tracing::warn!("timer sweep failed: {e}"), + } + } + }); + } + + // Nightly retention: trim the append-only logs to MAX_LOG_ROWS at ~00:00 UTC + // (run once on startup too, to bound an already-large DB immediately). + { + let db = state.db.clone(); + tokio::spawn(async move { + loop { + match retention_sweep(&db).await { + Ok((a, e)) if a + e > 0 => tracing::info!("retention: trimmed {a} audit + {e} event row(s)"), + Ok(_) => {} + Err(e) => tracing::warn!("retention sweep failed: {e}"), + } + tokio::time::sleep(Duration::from_secs(secs_until_next_utc_midnight())).await; + } + }); + } + + let app = Router::new() + .route("/health", get(|| async { "ok" })) + // Dashboard read APIs — ALL require a valid token now (no anonymous access): + // both admins and ml_support must sign in before any data is returned. + .route("/api/clients", get(list_clients).post(admin::create_client)) + .route("/api/clients/:id", delete(admin::delete_client)) + .route("/api/projects", get(list_projects).post(admin::create_project)) + .route("/api/projects/:id", delete(admin::delete_project)) + .route("/api/projects/:id/source", post(admin::update_project_source)) + .route("/api/projects/:id/ingest", post(trigger_ingest)) + .route("/api/projects/:id/videos", get(list_videos)) + .route("/api/projects/:id/stats", get(stats)) + .route("/api/projects/:id/activity", get(activity)) + .route("/api/audit", get(list_audit)) + .route("/api/videos/:id/remarks", get(list_remarks).post(add_remark)) + .route("/api/videos/:id/hand", post(set_hand)) + .route("/api/videos/:id/ignore", post(set_ignore)) + // Export downloads are hit via , which can't set a header — they + // authenticate with a `?token=` query param instead (validated in-handler). + .route("/api/videos/:id/export", get(export_video)) + .route("/api/projects/:id/export", get(export_project)) + .route("/api/projects/:id/assign", post(assign_videos)) + .route("/api/projects/:id/members", get(admin::list_members).post(admin::add_member)) + .route("/api/projects/:id/members/:username", delete(admin::remove_member)) + .route("/api/projects/:id/timer", post(project_timer)) + .route("/api/projects/:id/folders", get(list_folders).post(set_folders)) + // Phase 3 admin (token-gated). + .route("/api/admin/storage", get(storage_info)) + .route("/api/users", get(admin::list_users).post(admin::create_user)) + .route("/api/users/:id", delete(admin::delete_user)) + .route("/api/users/:id/update", post(admin::update_user)) + .route("/api/users/:id/token", post(admin::rotate_token)) + // Phase 3 worker pull/push (token-gated). + .route("/api/auth/whoami", get(worker::whoami)) + .route("/api/auth/login", post(worker::login)) + .route("/api/videos/:id/claim", post(worker::claim)) + .route("/api/videos/:id/download", get(worker::download)) + .route("/api/videos/:id/heartbeat", post(worker::heartbeat)) + .route("/api/videos/:id/release", post(worker::release)) + .route("/api/videos/:id/push", post(worker::push)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let addr: SocketAddr = bind.parse()?; + tracing::info!("listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +/// Trim the append-only logs to the most-recent `MAX_LOG_ROWS` rows so the DB stays +/// bounded. `audit_events` is trimmed wholesale; `video_events` keeps ALL 'push' +/// rows (load-bearing) and trims the rest. Returns (audit_deleted, events_deleted). +async fn retention_sweep(db: &PgPool) -> Result<(u64, u64), sqlx::Error> { + let audit = sqlx::query( + "DELETE FROM audit_events WHERE id NOT IN (SELECT id FROM audit_events ORDER BY at DESC, id DESC LIMIT $1)", + ) + .bind(MAX_LOG_ROWS) + .execute(db) + .await? + .rows_affected(); + let events = sqlx::query( + r#"DELETE FROM video_events + WHERE event <> 'push' + AND id NOT IN ( + SELECT id FROM video_events WHERE event <> 'push' ORDER BY at DESC, id DESC LIMIT $1 + )"#, + ) + .bind(MAX_LOG_ROWS) + .execute(db) + .await? + .rows_affected(); + Ok((audit, events)) +} + +/// Seconds from now until the next 00:00 UTC (the nightly retention tick). +fn secs_until_next_utc_midnight() -> u64 { + let now = chrono::Utc::now(); + let next = (now + chrono::Duration::days(1)) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + (next - now).num_seconds().max(60) as u64 +} + +/// `GET /api/admin/storage` — database size + row counts (admin only). +async fn storage_info(State(s): State, user: AuthUser) -> ApiResult { + user.require_admin()?; + let (db_bytes, videos, annotations, video_events, audit_events): (i64, i64, i64, i64, i64) = + sqlx::query_as( + r#"SELECT pg_database_size(current_database())::bigint, + (SELECT count(*) FROM videos)::bigint, + (SELECT count(*) FROM annotations)::bigint, + (SELECT count(*) FROM video_events)::bigint, + (SELECT count(*) FROM audit_events)::bigint"#, + ) + .fetch_one(&s.db) + .await + .map_err(err)?; + Ok(Json(StorageInfo { + db_bytes, videos, annotations, video_events, audit_events, + max_log_rows: MAX_LOG_ROWS, + })) +} + +/// Flip every expired, unfinished claim back to available and log an `auto_release` +/// event for each. Returns the number released. +async fn release_expired_leases(db: &PgPool) -> Result { + let res = sqlx::query( + r#" + WITH expired AS ( + UPDATE videos + SET claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL + WHERE claimed_by IS NOT NULL AND lease_expires_at < now() AND status <> 'verified' + RETURNING id + ) + INSERT INTO video_events (video_id, username, event) + SELECT id, '', 'auto_release' FROM expired + "#, + ) + .execute(db) + .await?; + Ok(res.rows_affected()) +} + +/// Auto-stop project timers idle for >72h: accumulate up to the last activity (so the +/// idle stretch isn't counted) and pause. Resumes automatically on the next push. +async fn auto_stop_idle_timers(db: &PgPool) -> Result { + let res = sqlx::query( + r#"UPDATE projects SET + time_accumulated_ms = time_accumulated_ms + CASE WHEN time_started_at IS NOT NULL + THEN GREATEST(0, (EXTRACT(EPOCH FROM (LEAST(last_activity_at, now()) - time_started_at)) * 1000)::bigint) + ELSE 0 END, + time_running = FALSE, + time_started_at = NULL + WHERE time_running AND last_activity_at IS NOT NULL + AND now() - last_activity_at > interval '72 hours'"#, + ) + .execute(db) + .await?; + Ok(res.rows_affected()) +} + +/// `POST /api/projects/:id/timer {action}` — start | stop | reset the project timer +/// (admin only). start = resume (no-op if already running); stop = pause; reset = zero. +async fn project_timer( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let exists: Option = sqlx::query_scalar("SELECT id FROM projects WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, "project not found".into())); + } + let sql = match req.action.as_str() { + "start" => { + "UPDATE projects SET time_running=TRUE, last_activity_at=now(), + time_started_at = CASE WHEN time_running THEN time_started_at ELSE now() END + WHERE id=$1" + } + "stop" => { + "UPDATE projects SET + time_accumulated_ms = time_accumulated_ms + CASE WHEN time_running AND time_started_at IS NOT NULL + THEN GREATEST(0, (EXTRACT(EPOCH FROM (now() - time_started_at)) * 1000)::bigint) ELSE 0 END, + time_running=FALSE, time_started_at=NULL + WHERE id=$1" + } + "reset" => { + "UPDATE projects SET time_accumulated_ms=0, last_activity_at=now(), + time_started_at = CASE WHEN time_running THEN now() ELSE NULL END + WHERE id=$1" + } + _ => return Err((StatusCode::BAD_REQUEST, "action must be start, stop or reset".into())), + }; + sqlx::query(sql).bind(id).execute(&s.db).await.map_err(err)?; + admin::audit(&s.db, &user.username, "project_timer", json!({ "project_id": id, "action": req.action })).await; + Ok(Json(json!({ "action": req.action }))) +} + +/// `GET /api/projects/:id/folders` — folders in the project + video counts + whether +/// each is currently included in verification. Admin or member. +async fn list_folders(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult> { + require_project_access(&s, &user, id).await?; + let rows = sqlx::query_as::<_, FolderRow>( + r#"SELECT d.folder, d.video_count, (pf.folder IS NOT NULL) AS included + FROM (SELECT CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path, '/[^/]*$', '') ELSE '' END AS folder, + count(*)::bigint AS video_count + FROM videos WHERE project_id=$1 GROUP BY 1) d + LEFT JOIN project_folders pf ON pf.project_id=$1 AND pf.folder=d.folder + ORDER BY d.folder"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/projects/:id/folders {folders}` — replace the considered-folders set +/// (admin only). Empty list = consider all folders again. +async fn set_folders( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let mut tx = s.db.begin().await.map_err(err)?; + sqlx::query("DELETE FROM project_folders WHERE project_id=$1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(err)?; + for f in &req.folders { + sqlx::query("INSERT INTO project_folders (project_id, folder) VALUES ($1,$2) ON CONFLICT DO NOTHING") + .bind(id) + .bind(f) + .execute(&mut *tx) + .await + .map_err(err)?; + } + tx.commit().await.map_err(err)?; + admin::audit(&s.db, &user.username, "folders_updated", json!({ "project_id": id, "count": req.folders.len() })).await; + Ok(Json(json!({ "folders": req.folders.len() }))) +} + +async fn connect_with_retry(url: &str, attempts: u32) -> anyhow::Result { + let mut last = None; + for i in 1..=attempts { + match PgPoolOptions::new().max_connections(10).connect(url).await { + Ok(pool) => return Ok(pool), + Err(e) => { + tracing::warn!("db connect attempt {i}/{attempts} failed: {e}"); + last = Some(e); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + Err(anyhow::anyhow!("db connect failed: {:?}", last)) +} + +/// True if the user may access this project: admins always; others only if a member. +async fn is_member_or_admin(s: &AppState, user: &AuthUser, project_id: i32) -> Result { + if user.is_admin() { + return Ok(true); + } + let m: Option = sqlx::query_scalar( + "SELECT 1 FROM project_members WHERE project_id=$1 AND username=$2", + ) + .bind(project_id) + .bind(&user.username) + .fetch_optional(&s.db) + .await + .map_err(err)?; + Ok(m.is_some()) +} + +/// 403 unless the caller is an admin or a member of this project. +async fn require_project_access(s: &AppState, user: &AuthUser, project_id: i32) -> Result<(), (StatusCode, String)> { + if is_member_or_admin(s, user, project_id).await? { + Ok(()) + } else { + Err((StatusCode::FORBIDDEN, "you are not assigned to this project".into())) + } +} + +async fn list_projects(State(s): State, user: AuthUser) -> ApiResult> { + // Non-admins only see projects they're a member of (picked by an admin). + let rows: Vec<(i32, String, String, String, Option, String)> = sqlx::query_as( + r#"SELECT p.id, p.name, p.source_path, p.source_kind, p.client_id, COALESCE(c.name,'') + FROM projects p LEFT JOIN clients c ON p.client_id=c.id + WHERE $1 OR EXISTS (SELECT 1 FROM project_members m WHERE m.project_id=p.id AND m.username=$2) + ORDER BY c.name NULLS FIRST, p.name"#, + ) + .bind(user.is_admin()) + .bind(&user.username) + .fetch_all(&s.db) + .await + .map_err(err)?; + let mut out = Vec::new(); + for (id, name, source_path, source_kind, client_id, client_name) in rows { + let (total, pending, annotated, verified) = counts(&s.db, id).await.map_err(err)?; + out.push(ProjectSummary { + id, name, source_path, source_kind, client_id, client_name, + total, pending, annotated, verified, + }); + } + Ok(Json(out)) +} + +/// `GET /api/clients` — list clients with their project counts (auth required). +async fn list_clients(State(s): State, _user: AuthUser) -> ApiResult> { + let rows: Vec<(i32, String, chrono::DateTime, i64)> = sqlx::query_as( + r#"SELECT c.id, c.name, c.created_at, COUNT(p.id) + FROM clients c LEFT JOIN projects p ON p.client_id=c.id + GROUP BY c.id, c.name, c.created_at + ORDER BY c.name"#, + ) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json( + rows.into_iter() + .map(|(id, name, created_at, project_count)| ClientRow { id, name, created_at, project_count }) + .collect(), + )) +} + +async fn trigger_ingest( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + // Resolve the project's source folder (mounting an NFS share if `source_kind='nfs'`). + let row: Option<(String, String)> = + sqlx::query_as("SELECT source_path, source_kind FROM projects WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let (source_path, source_kind) = row.unwrap_or_else(|| { + (s.ingest_dir.to_string_lossy().to_string(), "local".to_string()) + }); + + let (sp, nfs_host, nfs_opts) = (source_path.clone(), s.nfs_host.clone(), s.nfs_opts.clone()); + let dir = tokio::task::spawn_blocking(move || nfs::resolve_dir(&sp, &source_kind, &nfs_host, &nfs_opts)) + .await + .map_err(err)? + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let n = ingest::ingest_project(&s.db, id, &dir) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + admin::audit(&s.db, &user.username, "synced", serde_json::json!({ "project_id": id, "ingested": n })).await; + // Report the export path, never the resolved local mountpoint / host. + Ok(Json(serde_json::json!({ "ingested": n, "source_path": source_path }))) +} + +#[derive(serde::Deserialize)] +struct AuditQuery { + limit: Option, +} + +/// Query-param auth for file downloads (`` can't send an auth header). +#[derive(serde::Deserialize)] +struct TokenQuery { + token: Option, +} + +/// `GET /api/audit?limit=100` — recent org-level audit events (auth required). +async fn list_audit(State(s): State, _user: AuthUser, Query(q): Query) -> ApiResult> { + let limit = q.limit.unwrap_or(100).clamp(1, 1000); + let rows = sqlx::query_as::<_, AuditRow>( + "SELECT at, username, action, detail FROM audit_events ORDER BY at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +async fn list_videos(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult> { + require_project_access(&s, &user, id).await?; + let q = format!(r#"SELECT id, file_name, rel_path, has_json, width, height, fps, frame_count, + annotation_count, imported_count, annotation_time_ms, primary_annotator, status, annotated_at, + claimed_by, claimed_at, lease_expires_at, completed_by, completed_at, verify_time_ms, + -- annotations flagged for review (review_status='flagged') still open on this video + COALESCE((SELECT count(*) FROM annotations a WHERE a.video_id = videos.id AND a.review_status='flagged'), 0) AS review_count, + -- pass count + distinct verifiers (in first-push order), derived from the push log + COALESCE((SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push'), 0) AS verify_count, + COALESCE((SELECT string_agg(s.u, ', ' ORDER BY s.first_at) + FROM (SELECT username AS u, min(at) AS first_at FROM video_events + WHERE video_id = videos.id AND event='push' GROUP BY username) s), '') AS verifiers, + assigned_to, + -- derived lifecycle label for the UI badge + CASE + WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress' + WHEN videos.status = 'verified' THEN + CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2 + THEN 're-verified' ELSE 'verified' END + WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned' + ELSE videos.status + END AS workflow_status, + hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by, + COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at) + ORDER BY r.created_at) + FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks + FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#); + let rows = sqlx::query_as::<_, VideoRow>(&q) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `GET /api/videos/:id/export` — download one video's annotations as the canonical +/// export JSON (`videos.raw_json`, the doc pushed by the worker / ingested from NAS). +/// Auth required (via `?token=`); served as a file attachment. +async fn export_video(State(s): State, Query(q): Query, AxPath(id): AxPath) -> Result { + if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { + return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); + } + let row: Option<(String, Option)> = + sqlx::query_as("SELECT file_name, raw_json FROM videos WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let (file_name, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; + let doc = raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })); + let body = serde_json::to_vec_pretty(&doc).map_err(err)?; + let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name); + let fname = format!("{stem}_annotations.json"); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{fname}\"")) + .body(Body::from(body)) + .map_err(err) +} + +/// `GET /api/projects/:id/export` — download ALL annotated videos in a project as one +/// JSON ({project_id, video_count, videos:[{video_id, file_name, rel_path, annotations}]}). +async fn export_project(State(s): State, Query(q): Query, AxPath(id): AxPath) -> Result { + if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { + return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); + } + let rows: Vec<(i32, String, String, Option)> = sqlx::query_as( + "SELECT id, file_name, rel_path, raw_json FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path", + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + let videos: Vec = rows + .into_iter() + .map(|(vid, fname, rel, raw)| json!({ "video_id": vid, "file_name": fname, "rel_path": rel, "annotations": raw })) + .collect(); + let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos }); + let body = serde_json::to_vec_pretty(&doc).map_err(err)?; + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"project_{id}_annotations.json\"")) + .body(Body::from(body)) + .map_err(err) +} + +/// `POST /api/projects/:id/assign {video_ids, assignee}` — assign (or unassign with an +/// empty assignee) a set of videos to a user. Any authenticated user (admins/collaborators). +async fn assign_videos( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + if req.video_ids.is_empty() { + return Err((StatusCode::BAD_REQUEST, "no videos selected".into())); + } + let assignee = req.assignee.trim().to_string(); + if !assignee.is_empty() { + // The assignee must be a picked member of this project. + let member: Option = sqlx::query_scalar( + "SELECT username FROM project_members WHERE project_id=$1 AND username=$2", + ) + .bind(id) + .bind(&assignee) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if member.is_none() { + return Err((StatusCode::BAD_REQUEST, format!("'{assignee}' is not a member of this project"))); + } + } + let value: Option = if assignee.is_empty() { None } else { Some(assignee.clone()) }; + let n = sqlx::query("UPDATE videos SET assigned_to=$3 WHERE project_id=$1 AND id = ANY($2)") + .bind(id) + .bind(&req.video_ids) + .bind(&value) + .execute(&s.db) + .await + .map_err(err)? + .rows_affected(); + admin::audit(&s.db, &user.username, "videos_assigned", + json!({ "project_id": id, "assignee": assignee, "count": n })).await; + Ok(Json(json!({ "assigned": n, "assignee": assignee }))) +} + +/// `GET /api/videos/:id/remarks` — the full thread (auth required, like the video list). +async fn list_remarks(State(s): State, _user: AuthUser, AxPath(id): AxPath) -> ApiResult> { + let rows = sqlx::query_as::<_, Remark>( + "SELECT username, body, created_at FROM video_remarks WHERE video_id=$1 ORDER BY created_at", + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/videos/:id/remarks {body}` — append a remark. Any authenticated user +/// (admin or worker); attributed to their username. Returns the updated thread. +async fn add_remark( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult> { + let body = req.body.trim(); + if body.is_empty() { + return Err((StatusCode::BAD_REQUEST, "remark is required".into())); + } + let exists: Option = sqlx::query_scalar("SELECT id FROM videos WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, "video not found".into())); + } + sqlx::query("INSERT INTO video_remarks (video_id, username, body) VALUES ($1,$2,$3)") + .bind(id) + .bind(&user.username) + .bind(body) + .execute(&s.db) + .await + .map_err(err)?; + let rows = sqlx::query_as::<_, Remark>( + "SELECT username, body, created_at FROM video_remarks WHERE video_id=$1 ORDER BY created_at", + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/videos/:id/hand {raised}` — raise or lower the "hand" on a video. +/// Any authenticated user (ml_support or admin) can raise (to start a conversation) +/// or lower it. Raising records who/when; lowering clears that. +async fn set_hand( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + let updated: Option = if req.raised { + sqlx::query_scalar( + "UPDATE videos SET hand_raised=TRUE, hand_raised_by=$2, hand_raised_at=now() WHERE id=$1 RETURNING id", + ) + .bind(id) + .bind(&user.username) + .fetch_optional(&s.db) + .await + .map_err(err)? + } else { + sqlx::query_scalar( + "UPDATE videos SET hand_raised=FALSE, hand_raised_by=NULL, hand_raised_at=NULL WHERE id=$1 RETURNING id", + ) + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)? + }; + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "video not found".into())); + } + worker::log_event(&s.db, id, &user.username, + if req.raised { "hand_raised" } else { "hand_lowered" }, json!({})).await; + Ok(Json(json!({ "hand_raised": req.raised, "by": if req.raised { user.username } else { String::new() } }))) +} + +/// `POST /api/videos/:id/ignore {ignored}` — mark a video ignored / un-ignored. +/// **Admin only.** Ignored videos are crossed out in the UI (excluded from the +/// active workflow); the flag is orthogonal to status/claim. +async fn set_ignore( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let by: Option = if req.ignored { Some(user.username.clone()) } else { None }; + let updated: Option = sqlx::query_scalar( + "UPDATE videos SET ignored=$2, ignored_by=$3 WHERE id=$1 RETURNING id", + ) + .bind(id) + .bind(req.ignored) + .bind(&by) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "video not found".into())); + } + worker::log_event(&s.db, id, &user.username, + if req.ignored { "ignored" } else { "unignored" }, json!({})).await; + Ok(Json(json!({ "ignored": req.ignored }))) +} + +async fn counts(db: &PgPool, id: i32) -> Result<(i64, i64, i64, i64), sqlx::Error> { + let q = format!( + r#"SELECT COUNT(*), + COUNT(*) FILTER (WHERE status='pending'), + COUNT(*) FILTER (WHERE status='annotated'), + COUNT(*) FILTER (WHERE status='verified') + FROM videos WHERE project_id=$1 {FOLDER_FILTER}"#, + ); + sqlx::query_as(&q).bind(id).fetch_one(db).await +} + +async fn stats(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult { + require_project_access(&s, &user, id).await?; + let (total, pending, annotated, verified) = counts(&s.db, id).await.map_err(err)?; + let pct_done = if total > 0 { verified as f64 / total as f64 * 100.0 } else { 0.0 }; + + // Annotation leaderboard: credited per ANNOTATION author (annotated_by), so a + // verifier is credited only for annotations they actually DREW — imported + // annotations keep their original author and credit them, not the verifier. + // videos = distinct videos they drew in; time = annotation_time_ms of videos + // where they're the primary annotator. + let lead_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as( + r#"SELECT a.annotated_by, + COUNT(*)::bigint, + COUNT(DISTINCT a.video_id)::bigint, + COALESCE((SELECT SUM(v2.annotation_time_ms) FROM videos v2 + WHERE v2.project_id=$1 AND v2.primary_annotator = a.annotated_by), 0)::bigint + FROM annotations a JOIN videos v ON a.video_id=v.id + WHERE v.project_id=$1 AND a.annotated_by <> '' + GROUP BY a.annotated_by"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + let mut leaderboard: Vec = lead_rows + .into_iter() + .map(|(user, annotations, videos, total_time_ms)| LeaderRow { + avg_time_ms: if videos > 0 { total_time_ms / videos } else { 0 }, + annotations, + videos, + total_time_ms, + user, + }) + .collect(); + leaderboard.sort_by(|a, b| b.annotations.cmp(&a.annotations)); + // Annotations authored per user (for the verification leaderboard's "Annotations" col). + let ann_by_user: std::collections::HashMap = + leaderboard.iter().map(|r| (r.user.clone(), r.annotations)).collect(); + + // Verification leaderboard: ALL assigned users (members) ranked by completion + // SPEED (avg verify time per video, fastest first). Anyone who has completed + // videos but isn't a member is included too; members with no completions sort + // last (unranked). Refreshed every poll → "live" ranking. + let ver_rows: Vec<(String, i64, i64)> = sqlx::query_as( + r#"SELECT completed_by, COUNT(*), COALESCE(SUM(verify_time_ms),0)::bigint + FROM videos + WHERE project_id=$1 AND status='verified' AND completed_by IS NOT NULL AND completed_by <> '' + GROUP BY completed_by"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + let members: Vec = sqlx::query_scalar("SELECT username FROM project_members WHERE project_id=$1") + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + let mut stats_by_user: std::collections::HashMap = + ver_rows.into_iter().map(|(u, v, t)| (u, (v, t))).collect(); + for m in &members { + stats_by_user.entry(m.clone()).or_insert((0, 0)); + } + let mut verifiers: Vec = stats_by_user + .into_iter() + .map(|(user, (videos, total_time_ms))| VerifierRow { + avg_time_ms: if videos > 0 { total_time_ms / videos } else { 0 }, + videos, + total_time_ms, + annotations: ann_by_user.get(&user).copied().unwrap_or(0), + rank: 0, + user, + }) + .collect(); + // Fastest first: completers by avg ascending; non-completers last by name. + verifiers.sort_by(|a, b| match (a.videos > 0, b.videos > 0) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + (true, true) => a.avg_time_ms.cmp(&b.avg_time_ms).then_with(|| a.user.cmp(&b.user)), + (false, false) => a.user.cmp(&b.user), + }); + let mut rank = 0i64; + for v in verifiers.iter_mut() { + if v.videos > 0 { + rank += 1; + v.rank = rank; + } + } + + // Verification pace: push (verify) events in the recent window (folder-aware). + let recent_q = format!( + r#"SELECT COUNT(*)::bigint FROM video_events e JOIN videos v ON e.video_id=v.id + WHERE v.project_id=$1 AND e.event='push' AND e.at > now() - ($2::int * interval '1 day') + {FOLDER_FILTER}"#, + ); + let recent_verified: i64 = sqlx::query_scalar(&recent_q) + .bind(id) + .bind(RECENT_WINDOW_DAYS as i32) + .fetch_one(&s.db) + .await + .map_err(err)?; + + // Project time metrics: aggregate effort + avg per verified video (over the + // considered folders), the admin-controlled timer's elapsed, and a worker- + // parallelised ETA for the remaining (un-verified) videos. + let active_workers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_members WHERE project_id=$1") + .bind(id) + .fetch_one(&s.db) + .await + .map_err(err)?; + + let agg_q = format!( + r#"SELECT COALESCE(SUM(annotation_time_ms + verify_time_ms), 0)::bigint, + COALESCE(AVG(annotation_time_ms + verify_time_ms) FILTER (WHERE status='verified'), 0)::bigint + FROM videos WHERE project_id=$1 {FOLDER_FILTER}"#, + ); + let (total_spent_ms, avg_video_ms): (i64, i64) = + sqlx::query_as(&agg_q).bind(id).fetch_one(&s.db).await.map_err(err)?; + + // Project timer: elapsed = accumulated + (running ? now - started_at : 0). + let (time_running, t_acc, t_started, last_activity_at): ( + bool, i64, + Option>, + Option>, + ) = sqlx::query_as( + "SELECT time_running, time_accumulated_ms, time_started_at, last_activity_at FROM projects WHERE id=$1", + ) + .bind(id) + .fetch_one(&s.db) + .await + .map_err(err)?; + let live = if time_running { + t_started.map(|st| (chrono::Utc::now() - st).num_milliseconds().max(0)).unwrap_or(0) + } else { + 0 + }; + let elapsed_ms = t_acc + live; + + let all_verified = total > 0 && verified == total; + // Remaining work spread across the picked workers (≥1). Needs at least one + // verified video to have an average to extrapolate from. + let remaining = (total - verified).max(0); + let eta_ms = if avg_video_ms > 0 && remaining > 0 { + Some(remaining * avg_video_ms / active_workers.max(1)) + } else { + None + }; + + Ok(Json(Stats { + overview: Overview { total, pending, annotated, verified, pct_done }, + leaderboard, + verifiers, + timeline: ProjectTime { + active_workers, + avg_video_ms, + total_spent_ms, + elapsed_ms, + all_verified, + eta_ms, + time_running, + last_activity_at, + recent_verified, + recent_window_days: RECENT_WINDOW_DAYS, + }, + })) +} + +/// `GET /api/projects/:id/activity` — live claims + recent events for the dashboard. +async fn activity(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult { + require_project_access(&s, &user, id).await?; + let active_claims = sqlx::query_as::<_, ActiveClaim>( + r#"SELECT v.id AS video_id, v.file_name, v.claimed_by, v.claimed_at, v.lease_expires_at + FROM videos v + WHERE v.project_id=$1 AND v.claimed_by IS NOT NULL AND v.lease_expires_at > now() + ORDER BY v.claimed_at DESC"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + + let recent_events = sqlx::query_as::<_, EventRow>( + r#"SELECT e.video_id, v.file_name, e.username, e.event, e.at + FROM video_events e JOIN videos v ON e.video_id=v.id + WHERE v.project_id=$1 + ORDER BY e.at DESC LIMIT 50"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + + Ok(Json(Activity { active_claims, recent_events })) +} diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..66d4115 --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,426 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ---- Parsed from the sibling export-JSON (video-annotator export format) ---- + +#[derive(Deserialize, Default)] +pub struct ExportDoc { + #[serde(default)] + pub video: Option, + #[serde(default)] + pub fixed_annotations: Vec, + #[serde(default)] + pub range_annotations: Vec, +} + +#[derive(Deserialize, Default)] +pub struct ExportVideo { + #[serde(default)] + pub file_name: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub fps: Option, + #[serde(default)] + pub frame_count: Option, + #[serde(default)] + pub annotation_time_ms: i64, +} + +#[derive(Deserialize)] +pub struct FixedAnn { + #[serde(default)] + pub label: String, + #[serde(default)] + pub side: String, + #[serde(default)] + pub frame_number: i64, + #[serde(default, rename = "type")] + pub shape_type: String, + #[serde(default)] + pub vertices: Value, + #[serde(default)] + pub annotated_by: String, + #[serde(default)] + pub remark: String, + #[serde(default)] + pub review_status: String, + #[serde(default)] + pub subclass: String, + #[serde(default)] + pub created_at: String, +} + +#[derive(Deserialize)] +pub struct RangeAnn { + #[serde(default)] + pub label: String, + #[serde(default)] + pub side: String, + #[serde(default)] + pub start_frame: i64, + #[serde(default)] + pub start_vertices: Value, + #[serde(default)] + pub end_frame: Option, + #[serde(default)] + pub end_vertices: Option, + #[serde(default, rename = "type")] + pub shape_type: String, + #[serde(default)] + pub annotated_by: String, + #[serde(default)] + pub remark: String, + #[serde(default)] + pub review_status: String, + #[serde(default)] + pub subclass: String, + #[serde(default)] + pub created_at: String, +} + +// ---- API response shapes ---- + +#[derive(Serialize)] +pub struct ProjectSummary { + pub id: i32, + pub name: String, + pub source_path: String, + pub source_kind: String, + pub client_id: Option, + pub client_name: String, + pub total: i64, + pub pending: i64, + pub annotated: i64, + pub verified: i64, +} + +/// A client owning one or more projects (admin-created from the dashboard). +#[derive(Serialize)] +pub struct ClientRow { + pub id: i32, + pub name: String, + pub created_at: chrono::DateTime, + pub project_count: i64, +} + +/// Admin: `POST /api/clients` — create a client. +#[derive(Deserialize)] +pub struct NewClientRequest { + pub name: String, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct VideoRow { + pub id: i32, + pub file_name: String, + pub rel_path: String, + pub has_json: bool, + pub width: Option, + pub height: Option, + pub fps: Option, + pub frame_count: Option, + pub annotation_count: i32, + // Baseline count this video started a verification pass with: the NAS-ingested + // count, or (after a push) the count the worker had at claim time. Compared with + // annotation_count it shows how many the worker added/deleted. + pub imported_count: i32, + pub annotation_time_ms: i64, + pub primary_annotator: String, + pub status: String, + pub annotated_at: Option>, + // Phase 3: claim/verify dimension (orthogonal to `status`). + pub claimed_by: Option, + pub claimed_at: Option>, + pub lease_expires_at: Option>, + pub completed_by: Option, + pub completed_at: Option>, + pub verify_time_ms: i64, + // Annotations still flagged for review (review_status='flagged') on this video. + pub review_count: i64, + // How many times pushed (1 = verified, ≥2 = re-verified) + the distinct verifiers + // ("ravi, john"), both derived from the push event log. + pub verify_count: i64, + pub verifiers: String, + // Admin assignment (advisory owner) + the derived lifecycle label for the UI badge: + // pending | annotated | assigned | in-progress | verified | re-verified. + pub assigned_to: Option, + pub workflow_status: String, + // "Raise hand" signal — anyone may raise (to start a conversation) or lower it. + pub hand_raised: bool, + pub hand_raised_by: Option, + pub hand_raised_at: Option>, + // "Ignore" flag — admin-only; the UI crosses out ignored rows. + pub ignored: bool, + pub ignored_by: Option, + // Remark thread (json array of {username, body, created_at}); '[]' when none. + pub remarks: serde_json::Value, +} + +/// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video. +#[derive(Deserialize)] +pub struct HandRequest { + pub raised: bool, +} + +/// `POST /api/videos/:id/ignore {ignored}` — admin-only ignore toggle. +#[derive(Deserialize)] +pub struct IgnoreRequest { + pub ignored: bool, +} + +#[derive(Deserialize)] +pub struct AssignRequest { + pub video_ids: Vec, + pub assignee: String, // empty = unassign +} + +/// A user picked for a project (project_members joined with users). +#[derive(Serialize, sqlx::FromRow)] +pub struct MemberRow { + pub username: String, + pub display_name: String, + pub role: String, + pub added_at: chrono::DateTime, +} + +#[derive(Deserialize)] +pub struct AddMemberRequest { + pub username: String, +} + +/// One line in a video's remark thread. +#[derive(Serialize, sqlx::FromRow)] +pub struct Remark { + pub username: String, + pub body: String, + pub created_at: chrono::DateTime, +} + +#[derive(Deserialize)] +pub struct NewRemarkRequest { + pub body: String, +} + +#[derive(Serialize)] +pub struct Overview { + pub total: i64, + pub pending: i64, + pub annotated: i64, + pub verified: i64, + pub pct_done: f64, +} + +#[derive(Serialize)] +pub struct LeaderRow { + pub user: String, + pub videos: i64, + pub annotations: i64, + pub total_time_ms: i64, + pub avg_time_ms: i64, +} + +#[derive(Serialize)] +pub struct Stats { + pub overview: Overview, + pub leaderboard: Vec, + // Phase 3: verification leaderboard (by `completed_by`). + pub verifiers: Vec, + // Project time, verification pace + worker-based ETA. + pub timeline: ProjectTime, +} + +/// Aggregate project time metrics + a worker-parallelised ETA for the remaining work. +#[derive(Serialize)] +pub struct ProjectTime { + /// Users picked for the project (the parallelism factor for the ETA). + pub active_workers: i64, + /// Average effort per verified video (annotation + verify time). + pub avg_video_ms: i64, + /// Total time spent by all users across all videos (annotation + verify). + pub total_spent_ms: i64, + /// Wall-clock from project inception to last completion (or now if ongoing). + pub elapsed_ms: i64, + /// Whether every video is verified. + pub all_verified: bool, + /// Estimated remaining work time = remaining × avg_video ÷ active_workers. + pub eta_ms: Option, + /// Whether the project timer is currently running (accumulating). + pub time_running: bool, + /// Last verify/re-verify activity (drives the 72h auto-stop). + pub last_activity_at: Option>, + /// Verification pace: push (verify) events in the recent window, and the window + /// length in days. videos/day = recent_verified ÷ recent_window_days. + pub recent_verified: i64, + pub recent_window_days: i64, +} + +/// `POST /api/projects/:id/timer {action}` — start | stop | reset (admin only). +#[derive(Deserialize)] +pub struct TimerRequest { + pub action: String, +} + +/// `POST /api/projects/:id/folders {folders}` — set the folders to consider (admin). +#[derive(Deserialize)] +pub struct FoldersRequest { + pub folders: Vec, +} + +/// `GET /api/projects/:id/folders` — a folder in the project + its video count + +/// whether it's currently included in verification. +#[derive(Serialize, sqlx::FromRow)] +pub struct FolderRow { + pub folder: String, + pub video_count: i64, + pub included: bool, +} + +#[derive(Serialize)] +pub struct VerifierRow { + pub user: String, + pub videos: i64, + pub total_time_ms: i64, + pub avg_time_ms: i64, + /// Total annotations this user authored (annotated_by) in the project. + pub annotations: i64, + /// 1-based rank by completion speed (fastest avg first); 0 = no completions yet. + pub rank: i64, +} + +// ---- Phase 3: auth + worker pull/push shapes ---- + +/// `GET /api/auth/whoami` — identifies the bearer. +#[derive(Serialize)] +pub struct WhoAmI { + pub username: String, + pub role: String, +} + +/// `POST /api/videos/:id/claim` response — everything the desktop needs to verify. +#[derive(Serialize)] +pub struct ClaimResponse { + pub video_id: i32, + pub file_name: String, + pub rel_path: String, + pub width: Option, + pub height: Option, + pub fps: Option, + pub frame_count: Option, + pub lease_expires_at: chrono::DateTime, + pub download_url: String, + /// Full preloaded export doc (`{video, fixed_annotations[], range_annotations[]}`) + /// for the desktop to import via its existing import path. + pub annotations: Value, +} + +/// Admin: `POST /api/users` — provision a worker/admin. +#[derive(Deserialize)] +pub struct NewUserRequest { + pub username: String, + #[serde(default)] + pub display_name: String, + #[serde(default = "default_role")] + pub role: String, +} +fn default_role() -> String { + "ml_support".into() +} + +/// Admin: `POST /api/users/:id/update` — edit a user's display name + role. +#[derive(Deserialize)] +pub struct UpdateUserRequest { + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub role: String, +} + +/// Admin user-provision response — the **only** time the clear token is shown. +#[derive(Serialize)] +pub struct NewUserResponse { + pub username: String, + pub display_name: String, + pub role: String, + pub token: String, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct UserRow { + pub id: i32, + pub username: String, + pub display_name: String, + pub role: String, + pub active: bool, + pub created_at: chrono::DateTime, +} + +/// `POST /api/projects` — create a project under a client (any authenticated user). +#[derive(Deserialize)] +pub struct NewProjectRequest { + pub client_id: i32, + pub name: String, + pub source_path: String, + /// 'local' (default) or 'nfs'. + #[serde(default = "default_source_kind")] + pub source_kind: String, +} +fn default_source_kind() -> String { + "local".into() +} + +/// `POST /api/projects/:id/source` — change a project's sync directory (any authenticated user). +#[derive(Deserialize)] +pub struct UpdateSourceRequest { + pub source_path: String, + #[serde(default = "default_source_kind")] + pub source_kind: String, +} + +/// `GET /api/admin/storage` — DB size + row counts (admin only). +#[derive(Serialize)] +pub struct StorageInfo { + pub db_bytes: i64, + pub videos: i64, + pub annotations: i64, + pub video_events: i64, + pub audit_events: i64, + /// Retention cap applied nightly to the event/audit logs. + pub max_log_rows: i64, +} + +/// `GET /api/audit` — an org-level audit row. +#[derive(Serialize, sqlx::FromRow)] +pub struct AuditRow { + pub at: chrono::DateTime, + pub username: String, + pub action: String, + pub detail: Value, +} + +/// `GET /api/projects/:id/activity` — live claims + recent events. +#[derive(Serialize)] +pub struct Activity { + pub active_claims: Vec, + pub recent_events: Vec, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct ActiveClaim { + pub video_id: i32, + pub file_name: String, + pub claimed_by: String, + pub claimed_at: chrono::DateTime, + pub lease_expires_at: chrono::DateTime, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct EventRow { + pub video_id: i32, + pub file_name: String, + pub username: String, + pub event: String, + pub at: chrono::DateTime, +} diff --git a/server/src/nfs.rs b/server/src/nfs.rs new file mode 100644 index 0000000..ed0e4ab --- /dev/null +++ b/server/src/nfs.rs @@ -0,0 +1,146 @@ +//! Resolve a project's source folder to a real local directory the server can scan. +//! +//! - `local` projects: `source_path` is already a path inside the container (a plain folder, or a +//! compose-managed NFS volume) — returned as-is. +//! - `nfs` projects: the share is mounted on demand at `/mnt/nfs/`. `source_path` may be: +//! * a bare export path `/volume4/Share` — the host comes from the `NFS_HOST` env (IP hidden), or +//! * `host:/volume4/Share`, or `nfs://host/volume4/Share` — host taken from the path itself. + +use std::io::Read; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const NFS_MOUNT_ROOT: &str = "/mnt/nfs"; + +/// Fast reachability probe: can we open a TCP connection to the NAS's NFS port? +/// `mount.nfs` can hang for minutes on a filtered host (the connect is uninterruptible), +/// so we bound the unreachable case here to a few seconds and fail with a clean message. +fn probe_reachable(nfs_host: &str) -> Result<(), String> { + let addr = format!("{nfs_host}:2049"); + let sock = addr + .to_socket_addrs() + .map_err(|_| "could not resolve the configured NAS host".to_string())? + .next() + .ok_or_else(|| "could not resolve the configured NAS host".to_string())?; + TcpStream::connect_timeout(&sock, Duration::from_secs(3)) + .map(|_| ()) + .map_err(|_| "NAS not reachable (no NFS service on the configured host)".to_string()) +} + +/// Turn a string into a stable, filesystem-safe mountpoint dir name. +fn sanitize(s: &str) -> String { + s.trim_matches('/') + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' { c } else { '_' }) + .collect() +} + +/// Parse an nfs `source_path` into `(host, export)`. Accepts: +/// `nfs://host/volume4/Share`, `host:/volume4/Share`, or bare `/volume4/Share` +/// (bare → host falls back to the server's `NFS_HOST`). The export is always absolute. +fn parse_nfs(source_path: &str, default_host: &str) -> Result<(String, String), String> { + let s = source_path.trim(); + + // nfs://host/export + let scheme = s.strip_prefix("nfs://").or_else(|| s.strip_prefix("NFS://")); + if let Some(rest) = scheme { + let (host, export) = rest + .split_once('/') + .ok_or_else(|| "nfs:// path must include an export, e.g. nfs://host/volume4/Share".to_string())?; + if host.is_empty() { + return Err("nfs:// path is missing the host".into()); + } + return Ok((host.to_string(), format!("/{}", export.trim_start_matches('/')))); + } + + // host:/export (standard NFS device notation) + if let Some((host, export)) = s.split_once(':') { + if !host.is_empty() && export.starts_with('/') { + return Ok((host.to_string(), export.to_string())); + } + } + + // bare /export → host from server env + if s.starts_with('/') { + if default_host.is_empty() { + return Err("NFS host is not configured on the server (set NFS_HOST)".into()); + } + return Ok((default_host.to_string(), s.to_string())); + } + + Err(format!( + "invalid NFS path '{s}' — use /export, host:/export, or nfs://host/export" + )) +} + +/// Is `mp` already an active mountpoint? (Reads /proc/mounts — no extra binaries needed.) +fn is_mounted(mp: &Path) -> bool { + let target = mp.to_string_lossy(); + std::fs::read_to_string("/proc/mounts") + .map(|s| s.lines().any(|l| l.split(' ').nth(1) == Some(target.as_ref()))) + .unwrap_or(false) +} + +/// Resolve `(source_path, source_kind)` to a local directory, mounting an NFS share if needed. +/// `nfs_host`/`nfs_opts` come from server env. Blocking (runs a `mount` for nfs) — call inside +/// `spawn_blocking`. +pub fn resolve_dir( + source_path: &str, + source_kind: &str, + nfs_host: &str, + nfs_opts: &str, +) -> Result { + if source_kind != "nfs" { + return Ok(PathBuf::from(source_path)); + } + + let (host, export) = parse_nfs(source_path, nfs_host)?; + + let mp = PathBuf::from(NFS_MOUNT_ROOT).join(sanitize(&format!("{host}_{export}"))); + if is_mounted(&mp) { + return Ok(mp); + } + + // Fail fast if the NAS isn't reachable, before the (potentially long-hanging) mount. + probe_reachable(&host)?; + + std::fs::create_dir_all(&mp).map_err(|e| format!("mkdir {}: {e}", mp.display()))?; + let device = format!("{host}:{export}"); + // Spawn the mount and enforce the deadline ourselves: a `mount.nfs` stuck mid-handshake goes + // uninterruptible (D state) and ignores SIGTERM/SIGKILL, so `timeout` can't bound it. We poll + // and return a clean error at the deadline; an orphaned mount that later succeeds will be picked + // up by `is_mounted` on the next sync. + let mut child = Command::new("mount") + .args(["-t", "nfs", "-o", nfs_opts, &device, &mp.to_string_lossy()]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to run mount (is nfs-common installed?): {e}"))?; + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(mp), + Ok(Some(_)) => { + let mut err = String::new(); + if let Some(mut e) = child.stderr.take() { + let _ = e.read_to_string(&mut err); + } + let safe = err.replace(&host, ""); + return Err(format!("mount of '{export}' failed: {}", safe.trim())); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + return Err(format!( + "mount of '{export}' timed out — check the NAS exports this path to the server" + )); + } + std::thread::sleep(Duration::from_millis(300)); + } + Err(e) => return Err(format!("mount wait failed: {e}")), + } + } +} diff --git a/server/src/worker.rs b/server/src/worker.rs new file mode 100644 index 0000000..8a23719 --- /dev/null +++ b/server/src/worker.rs @@ -0,0 +1,301 @@ +//! Phase 3 worker pull/push API. All routes require a valid bearer token (`AuthUser`). +//! Flow: claim (atomic, leased) → download local copy → verify in the desktop → push. + +use crate::auth::AuthUser; +use crate::models::*; +use crate::{err, AppState, ApiResult}; +use axum::{ + body::Body, + extract::{Path as AxPath, State}, + http::{header, StatusCode}, + response::Response, + Json, +}; +use serde_json::{json, Value}; +use tokio_util::io::ReaderStream; + +/// Record an audit/analytics event. Best-effort: errors are logged, not surfaced. +pub async fn log_event(db: &sqlx::PgPool, video_id: i32, username: &str, event: &str, meta: Value) { + if let Err(e) = sqlx::query( + "INSERT INTO video_events (video_id, username, event, meta) VALUES ($1,$2,$3,$4)", + ) + .bind(video_id) + .bind(username) + .bind(event) + .bind(meta) + .execute(db) + .await + { + tracing::warn!("failed to log {event} event for video {video_id}: {e}"); + } +} + +/// `GET /api/auth/whoami` — validate the token and identify the caller. +pub async fn whoami(user: AuthUser) -> ApiResult { + Ok(Json(WhoAmI { username: user.username, role: user.role })) +} + +/// `POST /api/auth/login` — like whoami, but records a `signed_in` audit event so +/// admins can see who logged in (Activity log). Called once on an explicit sign-in, +/// not on every token check, so the log isn't spammed. +pub async fn login(State(s): State, user: AuthUser) -> ApiResult { + crate::admin::audit(&s.db, &user.username, "signed_in", json!({ "role": user.role })).await; + Ok(Json(WhoAmI { username: user.username, role: user.role })) +} + +/// `POST /api/videos/:id/claim` — atomically claim a video. **Any** status is claimable: +/// `pending` (annotate from scratch), `annotated` (verify), or `verified` (re-verify — +/// anyone may re-pull a completed video and push again). Unclaimed or lease-expired only; +/// same-user re-claim is allowed (resume). Returns metadata + any preloaded annotations. +pub async fn claim( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + let row: Option<(i32, String, String, Option, Option, Option, Option, chrono::DateTime, Option)> = + sqlx::query_as( + r#" + UPDATE videos + SET claimed_by=$2, claimed_at=now(), lease_expires_at=now() + ($3::int * interval '1 second') + WHERE id=$1 + AND (claimed_by IS NULL OR claimed_by=$2 OR lease_expires_at < now()) + RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json + "#, + ) + .bind(id) + .bind(&user.username) + .bind(s.lease_secs) + .fetch_optional(&s.db) + .await + .map_err(err)?; + + let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json)) = row + else { + // Distinguish "doesn't exist" from "not claimable". + let exists: Option = sqlx::query_scalar("SELECT id FROM videos WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + return Err(match exists { + None => (StatusCode::NOT_FOUND, "video not found".into()), + Some(_) => ( + StatusCode::CONFLICT, + "video is held by another worker".into(), + ), + }); + }; + + log_event(&s.db, video_id, &user.username, "claim", json!({})).await; + + Ok(Json(ClaimResponse { + video_id, + file_name, + rel_path, + width, + height, + fps, + frame_count, + lease_expires_at, + download_url: format!("/api/videos/{video_id}/download"), + annotations: raw_json.unwrap_or(Value::Null), + })) +} + +/// `GET /api/videos/:id/download` — stream the video bytes from the source folder +/// (the NAS mount on the VM). Only the current claimant (or an admin) may download, +/// so NAS credentials never leave the server. +pub async fn download( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> Result { + let row: Option<(String, String, Option)> = sqlx::query_as( + r#"SELECT v.rel_path, v.file_name, v.claimed_by + FROM videos v WHERE v.id=$1"#, + ) + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let (rel_path, file_name, claimed_by) = + row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; + + if !user.is_admin() && claimed_by.as_deref() != Some(user.username.as_str()) { + return Err((StatusCode::FORBIDDEN, "you do not hold this claim".into())); + } + + // Resolve against the project's source folder (mount NFS if `source_kind='nfs'`). + let (source_path, source_kind): (String, String) = sqlx::query_as( + "SELECT p.source_path, p.source_kind FROM projects p JOIN videos v ON v.project_id=p.id WHERE v.id=$1", + ) + .bind(id) + .fetch_one(&s.db) + .await + .map_err(err)?; + + let (sp, nfs_host, nfs_opts) = (source_path, s.nfs_host.clone(), s.nfs_opts.clone()); + let dir = tokio::task::spawn_blocking(move || crate::nfs::resolve_dir(&sp, &source_kind, &nfs_host, &nfs_opts)) + .await + .map_err(err)? + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + let full = dir.join(&rel_path); + let file = tokio::fs::File::open(&full).await.map_err(|e| { + (StatusCode::NOT_FOUND, format!("source file unavailable ({}): {e}", full.display())) + })?; + let len = file.metadata().await.map_err(err)?.len(); + let body = Body::from_stream(ReaderStream::new(file)); + + Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, len) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{file_name}\""), + ) + .body(body) + .map_err(err) +} + +/// `POST /api/videos/:id/heartbeat` — extend the lease while verifying. +pub async fn heartbeat( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + let new_lease: Option> = sqlx::query_scalar( + r#"UPDATE videos + SET lease_expires_at = now() + ($3::int * interval '1 second') + WHERE id=$1 AND claimed_by=$2 + RETURNING lease_expires_at"#, + ) + .bind(id) + .bind(&user.username) + .bind(s.lease_secs) + .fetch_optional(&s.db) + .await + .map_err(err)?; + + match new_lease { + Some(lease_expires_at) => Ok(Json(json!({ "lease_expires_at": lease_expires_at }))), + None => Err((StatusCode::CONFLICT, "you do not hold this claim".into())), + } +} + +/// `POST /api/videos/:id/release` — abandon a claim, returning the video to the pool. +pub async fn release( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + let released: Option = sqlx::query_scalar( + r#"UPDATE videos + SET claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL + WHERE id=$1 AND claimed_by=$2 + RETURNING id"#, + ) + .bind(id) + .bind(&user.username) + .fetch_optional(&s.db) + .await + .map_err(err)?; + + match released { + Some(_) => { + log_event(&s.db, id, &user.username, "release", json!({})).await; + Ok(Json(json!({ "released": true }))) + } + None => Err((StatusCode::CONFLICT, "you do not hold this claim".into())), + } +} + +/// `POST /api/videos/:id/push` — submit the verified export doc + verify time. +/// Replaces the annotations, marks the video `verified`, records the verifier and +/// time, and clears the claim. Reuses the same export wire format as ingest. +pub async fn push( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(body): Json, +) -> ApiResult { + // The current holder (or an admin) may push. A null holder (auto-released, not + // re-claimed) is tolerated; a different holder is rejected. + let claimed_by: Option> = + sqlx::query_scalar("SELECT claimed_by FROM videos WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let claimed_by = claimed_by.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; + if !user.is_admin() { + if let Some(holder) = claimed_by.as_deref() { + if holder != user.username { + return Err((StatusCode::CONFLICT, "claim is held by another worker".into())); + } + } + } + + let verify_time_ms = body.get("verify_time_ms").and_then(|v| v.as_i64()).unwrap_or(0); + // The desktop reports how many annotations it had at claim time (the preloaded + // baseline). 0/absent → keep the existing baseline (set at ingest), so we never + // wipe it with an old client that doesn't send the field. + let imported_count = body.get("imported_count").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let doc: ExportDoc = serde_json::from_value(body.clone()) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid export doc: {e}")))?; + + crate::ingest::replace_annotations(&s.db, id, &doc) + .await + .map_err(err)?; + let ann_count = (doc.fixed_annotations.len() + doc.range_annotations.len()) as i32; + + // Recompute the video-level annotator fields from the pushed doc (same idea as + // ingest) so the annotation leaderboard + throughput populate for pushed videos: + // primary_annotator = the most-frequent drawer; annotation_time_ms from the doc. + let mut freq: std::collections::HashMap = std::collections::HashMap::new(); + for a in &doc.fixed_annotations { + if !a.annotated_by.is_empty() { *freq.entry(a.annotated_by.clone()).or_default() += 1; } + } + for a in &doc.range_annotations { + if !a.annotated_by.is_empty() { *freq.entry(a.annotated_by.clone()).or_default() += 1; } + } + let primary_annotator = freq.into_iter().max_by_key(|(_, c)| *c).map(|(u, _)| u).unwrap_or_default(); + let ann_time_ms = doc.video.as_ref().map(|v| v.annotation_time_ms).unwrap_or(0); + + // Accumulate verify_time_ms across passes (re-verification adds up). The list + // of verifiers + the pass count are derived from video_events (each push logs one). + sqlx::query( + r#"UPDATE videos + SET status='verified', completed_by=$2, completed_at=now(), + verify_time_ms = COALESCE(videos.verify_time_ms, 0) + $3, + annotation_count=$4, raw_json=$5, + primary_annotator=$6, annotation_time_ms=$7, annotated_at=now(), + imported_count = COALESCE(NULLIF($8, 0), videos.imported_count), + claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL + WHERE id=$1"#, + ) + .bind(id) + .bind(&user.username) + .bind(verify_time_ms) + .bind(ann_count) + .bind(&body) + .bind(&primary_annotator) + .bind(ann_time_ms) + .bind(imported_count) + .execute(&s.db) + .await + .map_err(err)?; + + log_event(&s.db, id, &user.username, "push", json!({ "verify_time_ms": verify_time_ms })).await; + + // Mark project activity + auto-resume its timer (a verify/re-verify restarts it). + let _ = sqlx::query( + r#"UPDATE projects SET last_activity_at=now(), time_running=TRUE, + time_started_at = CASE WHEN time_running THEN time_started_at ELSE now() END + WHERE id = (SELECT project_id FROM videos WHERE id=$1)"#, + ) + .bind(id) + .execute(&s.db) + .await; + + Ok(Json(json!({ "verified": true, "annotation_count": ann_count }))) +}