Commit Graph

325 Commits

Author SHA1 Message Date
Davide Scaini a33fea91cf admin: mark ghost users (no DB account) and add Delete dir button
- /api/admin/disk now includes in_db flag per user (true if account exists in DB)
- Ghost users (directory exists, no DB account) show amber 'ghost' badge and only
  Diag + Delete dir buttons (no Re-extract, Rebuild, Reset pwd, Reset data)
- DELETE /api/admin/users/{handle}/directory wipes the entire directory and updates
  the root manifest; refuses if the account still exists in the DB
- Wires up rmdir-btn with a window.confirm before calling the new endpoint
2026-04-15 14:58:54 +02:00
Davide Scaini dfd56e4448 fix: handle absolute track_url paths in ActivityDetail
resolveShards also rewrites track_url to absolute paths (/data/…).
The trackUrl reactive statement only handled http:// prefixes,
producing double /data//data/… for the GeoJSON fetch → map had no track.
2026-04-15 14:50:10 +02:00
Davide Scaini f376b24106 fix: handle absolute detail_url paths in loadActivity and loadTimeseries
resolveShards rewrites detail_url to absolute paths (e.g. /data/brut/_merged/activities/{id}.json)
when fetching from a user shard. loadActivity and loadTimeseries only checked for http:// prefixes
and treated /data/... paths as relative, producing double /data//data/... in the fetch URL → 404.

Fix: treat URLs starting with / as already absolute, same as http:// URLs.
2026-04-15 14:44:34 +02:00
Davide Scaini 290eef6c72 metrics: guard against corrupted time streams causing OOM
Strava originals with absolute Unix timestamps stored as elapsed-second
offsets produce a t_max of ~1.6 billion. compute_mmp and compute_best_efforts
both create dense 1Hz arrays via range(t_min, t_max+1), which for a 1.6B
span allocates 44+ GB and OOM-kills the process. Add a >1-week sanity
check and return None early for corrupt streams.

Root cause: old Strava activities (seen from 1970-epoch start_date)
where the time stream contains absolute Unix timestamps instead of
elapsed seconds.
2026-04-15 14:06:20 +02:00
Davide Scaini 25d80c8132 reextract: process in batches of 100 to bound subprocess memory
One Python process for 2015 activities exhausts all RAM + swap on a
cheap VPS. Split into sequential batches of 100: each subprocess handles
100 activities and exits, returning all memory to the OS before the
next batch starts. The server chains batches in the SSE event_stream
and triggers a single rebuild when all batches complete.
2026-04-15 10:08:55 +02:00
Davide Scaini a67b237161 reextract: reclaim RSS with malloc_trim + gc.collect every 50 activities
CPython's allocator holds freed memory in arenas and doesn't return it to
the OS, causing RSS to grow throughout the 2015-activity loop even when
each iteration's objects are freed. Call gc.collect() + malloc_trim(0)
every 50 activities to return freed pages to the kernel and keep RSS flat.
2026-04-15 09:58:16 +02:00
Davide Scaini 062ade28d3 reextract: use venv bincio script, not uv, to spawn subprocess
uv is unreliable in systemd environments where PATH omits ~/.local/bin.
Use sys.executable's parent directory to find the venv's bincio script
directly — this always works since the server itself runs from the venv.
2026-04-15 09:50:50 +02:00
Davide Scaini 1a563012e2 reextract-originals: run as subprocess to avoid OOM
The in-process approach loaded all 2015 Strava originals into the server
process memory, causing OOM kills. Now spawns `bincio reextract-originals`
as a child process; heavy work runs in an isolated Python interpreter that
exits when done, freeing all memory.

Also adds `bincio reextract-originals` as a standalone CLI command that
prints JSON-lines progress to stdout — useful for running directly on the
VPS via SSH for large backlogs.
2026-04-15 09:42:31 +02:00
Davide Scaini 6890892654 trying to fix building of activities that fails because of OOM 2026-04-15 09:30:22 +02:00
Davide Scaini b01b00698c rewrite reextract with queue-based thread/async bridge
The per-call run_in_executor pattern caused network errors.
New approach: one thread runs the entire extraction loop and puts
SSE strings into an asyncio.Queue via call_soon_threadsafe; the
async generator drains the queue. This is the correct pattern for
background-thread + SSE streaming in FastAPI.
2026-04-15 09:14:18 +02:00
Davide Scaini 10dd1185b9 fix reextract: async generator + run_in_executor, imports at endpoint level
The sync generator was failing with a network error because Starlette's
iterate_in_threadpool doesn't properly propagate exceptions from sync
generators — the connection resets with no body.

Fix: convert event_stream to an async generator (Starlette handles these
natively without thread wrapping), move imports to the endpoint function
scope so failures raise HTTPException before the stream starts, and run
CPU-intensive work (parse + write) via loop.run_in_executor so the
async generator can actually yield between activities.
2026-04-15 09:05:29 +02:00
Davide Scaini 378cba85ad fix re-extract: add heartbeat yield, batch index writes, handle HTTP errors in UI
- Generator now yields a 'status' event immediately so the client can
  distinguish 'working' from 'failed silently before first event'
- Batch mode: call write_activity per file but write index.json and
  athlete.json only once at the end (was O(n²) — 2015 rewrites)
- JS: check r.ok before reading the body stream; show HTTP error detail
  instead of staying stuck at 'Starting…'
- Handle 'status' event type in the progress log
2026-04-15 08:57:21 +02:00
Davide Scaini 89b92397cf add re-extract from Strava originals endpoint and improve diag
- POST /api/admin/users/{handle}/reextract-originals: reads stored
  originals/strava/*.json and re-runs strava_to_parsed + ingest_parsed
  without hitting the Strava API; streams SSE progress; calls merge_all
  and rebuild on completion
- GET /api/admin/users/{handle}/diag: now shows _merged/activities/
  file counts, a sample of filenames in activities/ (with symlink flag),
  and lists pending_files by name
- Admin page: Re-extract button per user with live SSE progress modal
2026-04-15 08:08:57 +02:00
Davide Scaini 1e30f85bdc add structured logging and admin diagnostics to serve
- bincio.serve logger wired into uvicorn output: rebuild steps, upload
  errors, strava-zip progress all now appear in the server log
- _trigger_rebuild: capture stdout/stderr, log errors instead of silently
  discarding; exceptions logged with traceback instead of swallowed
- upload handler: log per-file errors with traceback; include error detail
  in the SSE event sent back to the browser
- strava-zip handler: log imported/error counts on completion
- GET /api/admin/users/{handle}/diag: snapshot of a user's data dir
  (file counts, sizes, index activity counts, pending uploads)
- POST /api/admin/users/{handle}/rebuild-sync: blocking rebuild that
  returns full stdout/stderr — for debugging without SSH log access
- Admin page: Diag button per user opens a modal showing the diag JSON
2026-04-14 22:53:31 +02:00
Davide Scaini fcc70a8d90 fix graph.html: set explicit pixel height for vis.js container
vis.js requires a pixel-sized container — flex:1 is ignored.
Use position:fixed toolbar + JS-measured height for the graph div,
stored as window._network for resize handling.
2026-04-14 22:48:37 +02:00
Davide Scaini a14cee8710 add architecture graph generator and docs
scripts/gen_graph.py parses FastAPI routes, frontend fetch() calls,
component imports, and Python imports to auto-generate:
- docs/architecture.mmd: Mermaid diagram with API/Pages/Components/Python subgraphs
- docs/graph.html: standalone vis.js interactive graph (dark theme, group filters,
  search highlight, click-to-highlight connected nodes)

docs-proposal.md: proposal for a docs/ folder structure, API documentation strategy,
and tooling recommendations (plain markdown → MkDocs Material).
2026-04-14 22:45:03 +02:00
Davide Scaini 9419bd0c20 document password reset flow in CLAUDE.md and reset-password page 2026-04-14 22:34:15 +02:00
Davide Scaini 8fbd9a95e8 stop pre-building activity pages to fix OOM build failure
getStaticPaths now returns [] — all /activity/{id}/ URLs are served by
the activity/index.html shell via nginx try_files and hydrated by
ActivityDetailLoader. Pre-rendering thousands of pages was exhausting
server RAM and killing the build. The dynamic loader already handles
public, unlisted, and local activities identically.
2026-04-14 22:22:34 +02:00
Davide Scaini 13643479ef add password reset via admin-generated one-time code
db.py: reset_codes table (code, handle, created_by, created_at,
expires_at, used_at); create_reset_code() invalidates any prior unused
code for the same handle; use_reset_code() validates handle match,
expiry (24 h), and single-use; change_password() updates the hash.

server.py: POST /api/admin/users/{handle}/reset-password-code (admin)
returns a code; POST /api/auth/reset-password (public) validates the
code + handle and sets the new password.

Admin page: "Reset pwd" button per user — shows the code inline on
click (monospace, click-to-copy).
/reset-password/ page: handle + code + new password form.
Login page: "Forgot password?" link.
2026-04-14 21:58:50 +02:00
Davide Scaini d2ba96c26a fix admin delete to wipe originals/edits/geojson; rename button to Reset data
The old DELETE /api/admin/users/{handle}/activities only removed *.json
files and _merged/, leaving originals/ (Strava FIT files) and edits/
untouched — causing the 968 MB disk usage after a delete.

_wipe_user_activities() now removes activities/, edits/, originals/,
_merged/, index.json, athlete.json, and .bincio_cache.json. Admin page
button renamed to "Reset data" with updated confirmation text.
2026-04-13 20:10:15 +02:00
Davide Scaini a75dfa160b F14: add per-activity delete (DELETE /api/activity/{id} + drawer button)
Server endpoint removes the activity JSON, GeoJSON, timeseries, sidecar
edit, and images directory. Also purges the dedup cache entry so the
file can be re-uploaded if needed. Runs merge_all + rebuild afterwards.

EditDrawer: two-click delete button (click once → "Confirm delete?",
click again → deletes). On success, dispatches 'deleted' event.
ActivityDetail navigates back to the feed on delete.
2026-04-13 19:35:40 +02:00
Davide Scaini e6bb6e61a2 fix elevation_gain_m null for modern Garmin FIT files; fix map flash
FIT parser: try enhanced_altitude before altitude. Barometric altimeters
on modern Garmins (Edge 540, 840, etc.) write enhanced_altitude in
record messages and total_ascent in lap messages. The old code read only
altitude, producing null elevation_m per point → null elevation_gain_m
at the activity root while laps had correct values from total_ascent.

ActivityMap: use preview_coords (passed from ActivitySummary) to
initialise the map at the activity's location on mount, eliminating the
flash of world-view before the async detail JSON / bbox arrives.
2026-04-13 19:18:37 +02:00
Davide Scaini e7eefa345e F17: replace merge_all with merge_one in upload_image and delete_image
Single-activity writes now trigger a fast merge_one instead of a full
user rebuild. post_activity was fixed earlier; this completes the fix
for upload_image and delete_image endpoints.
2026-04-13 19:03:46 +02:00
Davide Scaini 57fb7acd3d remove accidentally tracked local files; update .gitignore 2026-04-13 18:50:20 +02:00
Davide Scaini 5ad3aee8f6 rename privacy "private" → "unlisted"; enable GPS for unlisted
- "unlisted" = not shown in the public feed, but GPS track, timeseries
  and detail JSON are all accessible by direct URL (security by obscurity)
- "private" accepted as legacy alias everywhere (backward compat with
  existing data on disk)
- New writes from Strava sync / ZIP upload / sidecar use "unlisted"
- Only "no_gps" now suppresses the GPS track
- isUnlisted() helper in format.ts used by all Svelte/Astro components
- SCHEMA.md and CLAUDE.md document the privacy model and the distinction
  between "unlisted" and "no_gps"
2026-04-13 18:49:20 +02:00
Davide Scaini 2ebfc7046d fix: fallback to direct file fetch in ActivityDetailLoader
If the index-based lookup fails (shard fetch silently failed, stale
index state, etc.), try fetching the activity detail file directly from
each user shard's _merged/activities/ directory. This makes private
activities and newly-synced activities accessible even when the index
resolution fails.

Also add console.error logging when shards fail in resolveShards to
help diagnose root causes.
2026-04-13 13:04:46 +02:00
Davide Scaini 1587d1cdf3 - brut: _merged/index.json has 586 activities — the count when merge_all last ran. The SSE rebuild bug (already fixed) meant it never re-ran after the full Strava sync
added 3256 more.
  - danilo: _merged/ is 8 KB — basically empty. merge_all likely ran concurrently (multiple file uploads trigger multiple rebuilds without a lock in --no-build mode),
  causing a race where shutil.rmtree(merged_acts) from one run wiped what another run was writing.

  Two fixes: serialize --no-build rebuilds with the same lock, and add a "Rebuild" button to the admin page.

 Root causes fixed:
  1. merge_all race condition — --no-build rebuilds now hold _rebuild_lock, same as full builds
  2. The SSE rebuild-trigger bug (already fixed earlier) was brut's original cause
2026-04-13 12:35:05 +02:00
Davide Scaini 7b37f45180 Bug fixed — temp ZIPs now go to /tmp/ (system temp) and are always deleted in a finally block, so they can't leak. A startup hook also auto-cleans any leftovers on
next server restart.

  Admin page now shows:
  - Overall disk bar (used/free/%)
  - Per-user table: total, activities (with file count), originals (with Strava breakdown), merged, images
  - A mini bar per user showing relative size
  - Red ⚠ warning if orphaned temp ZIPs are still present for a user
  - Delete activities button (reloads sizes after)
2026-04-13 12:24:59 +02:00
Davide Scaini 7e526c14e1 fix commit d659b90cd9 2026-04-12 19:55:13 +02:00
Davide Scaini 79e428ff0f script to rebuild pages 2026-04-12 19:48:13 +02:00
Davide Scaini d659b90cd9 - DELETE /api/admin/users/{handle}/activities — deletes all activities/*.json, wipes _merged/ and
index.json, then triggers a rebuild. Admin-only.
  - /admin/ page — lists all users, each with a "Delete activities" button. Clicking asks for
  confirmation in a <dialog> before firing the request. Button shows "Deleted (N)" or an error inline.
  - "Admin" nav link — appears in the top-right for admins only, hidden for everyone else.
2026-04-12 17:46:28 +02:00
Davide Scaini 2774f436d8 login payoff 2026-04-12 15:47:27 +02:00
Davide Scaini 6d702ed454 modify post hook to install garmin packages 2026-04-12 15:47:09 +02:00
Davide Scaini f003fdd89f garmin sync first attempt 2026-04-12 15:36:21 +02:00
Davide Scaini 6c431e8821 Here's what was built and why each decision was made:
Key at data_dir.parent/.garmin_key — nginx serves location /data/ { alias /var/bincio/data/; } so
  anything inside that dir is reachable. The key lives one level up at /var/bincio/.garmin_key,
  outside nginx's reach.

  Two-layer storage — garmin_creds.json holds the encrypted email+password (needed for re-login when
  tokens expire); garmin_session/ holds the garth OAuth tokens in plain JSON (short-lived, not the
  user's actual password).

  test_login() — called by the connect endpoint before saving anything, so credentials are only
  persisted if they actually work.

  get_client() — tries the session first (fast, no network), falls back to full re-login
  transparently. The caller never needs to think about whether the session is fresh.
2026-04-12 15:12:20 +02:00
Davide Scaini e80231b442 fix strava sync rebuild: trigger before yielding done event, not after 2026-04-12 14:55:33 +02:00
Davide Scaini 78581d5487 redesign community page as sortable table 2026-04-11 14:50:54 +02:00
Davide Scaini 4d743412e1 Merge remote-tracking branch 'vps/main' 2026-04-11 14:41:20 +02:00
Davide Scaini 705e00f852 adding community tab 2026-04-11 14:39:19 +02:00
Davide Scaini 0b569b727c trigger site rebuild after new user registration so profile pages exist immediately 2026-04-11 14:19:40 +02:00
Davide Scaini 9015d97b18 trigger site rebuild after new user registration so profile pages exist immediately 2026-04-11 14:17:42 +02:00
Davide Scaini 5de9967127 fix upload 500: add missing _file_suffix to serve server; fix iOS file picker accept types 2026-04-11 14:12:54 +02:00
Davide Scaini f4008c0f51 pin @observablehq/plot to 0.6.17 2026-04-11 10:56:16 +02:00
Davide Scaini ff8981b3a1 fix power curve y-axis: use zero:true instead of domain:[0,null] 2026-04-11 10:54:13 +02:00
Davide Scaini 087ef1b776 fix power chart ranges 2026-04-11 09:02:58 +02:00
Davide Scaini 8219db7bfa shorten bincioactivity to ba on mobile 2026-04-11 09:01:47 +02:00
Davide Scaini 18551f9f36 document where feedback is saved 2026-04-11 09:01:34 +02:00
Davide Scaini ef5b06c5b3 trigger rebuild after activities upload 2026-04-11 08:47:27 +02:00
Davide Scaini 82830222ba For users uploading:
- POST /api/upload now returns text/event-stream instead of JSON
  - Per-file progress events stream back as each file is processed: ↓ 3/47 (6%) — morning_ride.fit
  - Final done event shows the summary: "12 added, 35 duplicates"
  - The Vite proxy is configured to stream this properly (no buffering)

  For the admin:
  - New GET /api/admin/jobs endpoint (admin-only) returns the list of active upload jobs, each with
  user, started_at, total, done, current (filename being processed)
  - A pulsing amber badge appears in the nav bar for admins when any user has an active upload running
   — it shows e.g. "2 uploads running" with a tooltip listing each user's progress (@alice: 12/50
  files)
  - Polls every 5 seconds, disappears automatically when all jobs finish
2026-04-11 08:33:21 +02:00
Davide Scaini 01db4eb9ae ingest activities.csv 2026-04-11 08:13:27 +02:00