This page documents the endpoints that record a flight from arm to report: mission executions, the asset upload callbacks that attach media and logs to a flight, and the archive / report endpoints the UI uses afterward. Two blueprints back this surface — src/routes/execution_routes.py and src/routes/asset_routes.py — and both deliberately split their endpoints into two auth zones:
  • Drone / gamepad callbacks (@check_vpn_ip) — the on-drone uploader and gamepad service call these over the WireGuard plane. There is no JWT; the caller is identified purely by its source IP.
  • UI endpoints (@jwt_required()) — the Dashboard reads flight history, triggers analysis, downloads archives, and manages report recipients with a Bearer JWT.
For the auth models in general (JWT Bearer vs VPN-source-IP vs token/signature) and the response-envelope conventions, see HTTP API Overview & Auth Models. The IP-trust mechanics behind @check_vpn_ip live in VPN IP Authentication & Jumphost Routing. The service-layer internals (log parsing, ZIP building, SMTP) are on Executions, Log Analysis & Reports and S3 Assets, HLS Video & Execution Archives.

The flight-data lifecycle

A single flight moves through both auth zones. The drone side (green) writes data during and after the flight; the UI side (blue) reads it back.

Drone callbacks (VPN-IP authenticated)

Every handler in this group resolves the calling drone the same way — generic_drone_service.get_drone_by_ip(request.vpn_ip) — where request.vpn_ip is set by the check_vpn_ip decorator from the 10.71.x source IP (or an X-Drone-IP: SKYHUB_SITL_* header for SITL). If no drone matches, the callback returns 404 “Drone not found”.
These endpoints carry no JWT and no per-request ownership token — anything that can present a 10.71.x source IP (or set X-Drone-IP behind the proxy when ENABLE_SITL) is trusted as that drone. Do not expose them outside the VPN plane. See src/middleware/drone_vpn.py.

Execution endpoints

Method & pathAuthPurpose
POST /api/v1/executions/startcheck_vpn_ipBegin tracking a flight (called on ARM)
POST /api/v1/executions/<id>/completecheck_vpn_ipFinish a flight (on land/disarm); enqueues or defers the report
POST /api/v1/executions/<id>/logcheck_vpn_ipAttach the uploaded .bin DataFlash log; triggers async analysis
GET /api/v1/executions/currentcheck_vpn_ipThe in-progress execution for the calling drone (or null)
Starts a MissionExecution in status in_progress. Body is optional; if mission_id is omitted it falls back to the drone’s currently loaded drone.mission_id (execution_routes.py:172-187).
// request (optional body)
{ "mission_id": 42 }
// 200 — get_success_response(execution.to_dict())
{ "success": true, "data": { "id": 101, "drone_id": 7, "mission_id": 42, "status": "in_progress", "started_at": "2026-07-03T09:12:00+00:00" } }
Marks the execution finished and computes duration_seconds. status must be one of completed | aborted | error (default completed); an unknown value returns 400. Ownership is re-verified against the calling drone: the execution must belong to drone.user_id (404 otherwise) and to drone.id (403 otherwise) — see execution_routes.py:237-242.After completion it branches on pending uploads (get_pending_asset_count):
  • 0 pendingreport_service.enqueue_archive_and_report(execution_id, app) runs the archive + report on a background thread pool.
  • >0 pendingmark_report_pending(execution_id); the report is deferred until the last complete_upload lands (see Deferred reports).
// request
{ "status": "completed", "notes": "clean landing" }
Links an already-uploaded log asset (asset_id) to the execution and kicks off background pymavlink analysis. It verifies both the execution and the asset belong to the calling drone/user before linking (execution_routes.py:311-321). Analysis only runs if the asset has an s3_key; otherwise it logs a warning and skips.
// request
{ "asset_id": 555 }
// 200
{ "success": true, "data": { "execution_id": 101, "log_asset_id": 555 } }
Returns the drone’s current in-progress execution via get_in_progress_execution(user_id, drone_id), or {"success": true, "data": null} when idle. The on-drone uploader uses this to discover which execution_id to tag its assets with.

Asset upload callbacks

The uploader performs a two-call handshake per file: authenticate_upload mints a presigned S3 PUT URL and (usually) a DB row, the drone uploads directly to S3, then complete_upload flips the asset to ready.
Method & pathAuthPurpose
POST /api/v1/authenticate_uploadcheck_vpn_ipPresigned S3 upload URL + create Asset (status pending)
POST /api/v1/complete_uploadcheck_vpn_ipMark the asset ready; may dispatch a deferred report
asset_routes.py returns the older raw-JSON envelope here ({"success": true, "url": ..., "asset_id": ...}), not the {success, data} wrapper used by execution routes. It also imports the response helpers from utils.common_helper rather than src.utils.common_helper — both module paths resolve to the same file, so keep both importable.
1

authenticate_upload

Body requires file_name; asset_type is one of video | image | logs. Pass execution_id to link the asset to a flight (this is what later triggers the deferred report). For non-video assets a checksum is required and is de-duplicated: an already-ready asset with the same checksum returns 409.
// request
{ "file_name": "flight_101.bin", "asset_type": "logs", "checksum": "sha256:…", "execution_id": 101 }
// 200
{ "success": true, "url": "https://s3…/drone/7/logs/flight_101.bin?X-Amz-…", "asset_id": 555 }
The presigned URL expiry is 3 hours (AssetService.expiration), and the S3 key scheme is drone/{drone_id}/{asset_type}/{file_name} (asset_service.py:169).
Video segments skip the DB. When mime_type == "video/mp2t" (HLS .ts segments), request_upload returns asset_id = None and creates no Asset row — only the .m3u8 manifest gets an Asset. Callbacks that key off asset_id must tolerate null for segments.
2

complete_upload

Body is { "asset_id": <int> }. The service sets the asset to ready, stamps modified, and generates a thumbnail for images. If the asset carries an execution_id, it then calls _dispatch_pending_report_if_ready(...) which may fire the deferred report.
// 200
{ "success": true, "asset_id": 555, "user_id": 3, "drone_id": 7 }

Deferred report logic

Because assets keep uploading after landing, the report cannot always be sent when complete fires. The gateway resolves this race with a two-trigger design driven by the MissionExecution.report_pending flag and the count of pending-status assets. Trigger points, both in code:
  • complete_execution (execution_routes.py:255-267) — enqueues immediately if nothing is pending, otherwise marks the report pending.
  • _dispatch_pending_report_if_ready (asset_routes.py:16-51) — runs after every complete_upload; when the execution is report_pending and get_pending_asset_count == 0, it calls report_service.send_report(execution_id) then clear_report_pending(execution_id).
Debugging stuck pending executions. A report can stay pending forever if:
  1. The last asset’s complete_upload never arrives (upload failed after authenticate_upload created the pending row), so pending_count never reaches 0.
  2. An asset is uploaded without execution_idcomplete_upload only calls the dispatcher when asset.execution_id is set, so it never re-checks that execution.
  3. The immediate path (enqueue_archive_and_report) uses a background thread pool, but the deferred path (send_report) runs synchronously inside the complete_upload request with generate_archive=True, so a slow archive build stalls that HTTP call. This asymmetry is intentional — preserve it or unify both on the pool.
The two paths also use different thread pools: log analysis uses ThreadPoolExecutor(max_workers=4) in execution_routes.py:32; archive+report uses ThreadPoolExecutor(max_workers=2, thread_name_prefix="archive_gen") in report_service.py:18.

Attaching a log runs LogAnalysisService.analyze_log_from_s3(s3_key), which downloads the ArduPilot .bin DataFlash log and parses it with pymavlink into a FlightStats dataclass (flight time, distance, altitude/speed, battery, GPS quality, vibration/clipping, mode changes, error subsystems). Results are written to the MissionExecution row via _persist_flight_statsexecution_service.update_flight_stats(...) (~40 stat columns). There are two entry points with different execution models:
EndpointAuthModelNotes
POST /executions/<id>/log_analyze_log_asynccheck_vpn_ipBackground (max_workers=4 pool)Fire-and-forget after linking the log
POST /executions/<id>/analyze-logjwt_requiredSynchronousUI waits for the result and gets the stats back
Both paths explicitly catch SystemExit — pymavlink calls sys.exit() on corrupted logs, which would otherwise kill the worker (execution_routes.py:675, :744). Preserve those guards. The synchronous analyze-log returns stats.to_dict(); if the execution has no linked log asset it returns 400 “No log asset linked to this execution”.

UI endpoints (JWT authenticated)

The Dashboard reads flight history and manages reports with a Bearer JWT. Every handler verifies ownership through the user_id from get_jwt_identity().

Executions

Method & pathPurpose
GET /api/v1/executions?limit&offsetAll executions for the current user (pagination clamped to 1–100)
GET /api/v1/executions/vehicle/<drone_id>?limit&offsetExecutions for one drone (403 if not owned)
GET /api/v1/executions/<id>Detail with enriched assets, counts, drone/mission names, and the log asset
DELETE /api/v1/executions/<id>Delete the execution, its archive, and all linked assets (S3 + DB)
GET /executions/{id}
object
Returns execution.to_dict() merged with drone_name, mission_name, asset_counts, assets (ready, non-log, each enriched with a presigned URL), and log_asset (returned separately, not in the assets array). Only ready assets are counted (execution_service.py:97-145).
DELETE /executions/<id> is atomic-ish: it deletes the archive (best-effort), then all execution assets via asset_service.delete_asset(...), and only deletes the execution row if asset deletion succeeds. If S3 deletion raises, it rolls back and returns 500 to avoid orphaned S3 objects (execution_routes.py:531-547).

Reports, archives & flight stats

Method & pathPurpose
POST /api/v1/executions/<id>/analyze-logSynchronous re-analysis; returns full FlightStats
GET /api/v1/executions/<id>/flight-statsStored stats from the last analysis (no re-parse)
GET /api/v1/executions/<id>/archivePresigned ZIP URL; generates the archive if missing
POST /api/v1/executions/<id>/archiveForce-regenerate the ZIP archive
POST /api/v1/executions/<id>/send-reportEmail the report to a specific address
GET /api/v1/user/report-emailsCurrent report recipients
PUT /api/v1/user/report-emailsUpdate the extra recipient list
Both delegate to archive_service.get_archive_download_url(execution_id, regenerate=…). The archive is a ZIP of the execution’s ready assets stored at drone/{drone_id}/archives/*.zip. The presigned download URL and the archive itself are valid for 7 days; the service auto-regenerates when the archive is missing or older than 7 days.
// 200 — status is one of ready | no_assets | error
{ "success": true, "data": {
  "status": "ready",
  "url": "https://s3…/drone/7/archives/exec_101.zip?X-Amz-…",
  "expires_at": "2026-07-10T09:12:00+00:00",
  "archive_generated_at": "2026-07-03T09:14:00+00:00"
} }
Reads the persisted columns directly — it does not re-run pymavlink. Returns flight_time_seconds, total_distance_meters, max_altitude_meters, avg_speed_ms, max_speed_ms, log_analyzed_at, and has_log_asset (execution_routes.py:983-993). If log_analyzed_at is null the log has not been analyzed yet.
Body is { "email": "<address>" }, validated against EMAIL_REGEX. Sends the report to that single address via report_service.send_report_to_email(...) (independent of the user’s configured recipient list). The address is masked in logs (_mask_email). Returns 400 on invalid format, 404 if the execution is not owned, 500 if the send fails.
GET returns { email, extra_emails (list), all_recipients }. PUT accepts { "extra_emails": "[email protected],[email protected]" } (comma-separated); each address is validated with EMAIL_REGEX, normalized, and stored on user.report_extra_emails (set to null when empty). all_recipients combines the account email with the extras.
// PUT request
{ "extra_emails": "[email protected], [email protected]" }

Asset read & download (JWT)

The same asset rows written by the drone callbacks are read back by the UI. These are the HTTP endpoints; the S3 key scheme, HLS rewriting, thumbnails, and two-phase delete are documented in S3 Assets, HLS Video & Execution Archives.
Method & pathPurpose
GET /api/v1/assets/userPer-vehicle asset counts for the user
GET /api/v1/assets/<vehicle_id>All assets for a vehicle (non-video get presigned download URLs)
GET /api/v1/assets/video/<vehicle_id>Video assets for a vehicle (404 if none)
GET /api/v1/assets/<vehicle_id>/video/<asset_id>Rewritten HLS .m3u8 playlist (application/vnd.apple.mpegurl)
GET /api/v1/assets/<vehicle_id>/download/<asset_id>Presigned download URL for any single asset
POST /api/v1/assets/<vehicle_id>/deleteDelete assets by ids list (S3 + DB); 400 on empty list
GET /assets/<vehicle_id>/video/<asset_id> returns the raw M3U8 body (not the {success, data} envelope) with mimetype="application/vnd.apple.mpegurl", because file:// segment lines are rewritten server-side into presigned .ts URLs so a browser HLS player can fetch them (asset_routes.py:361-397).

Where this connects

Service internals

ExecutionService, LogAnalysisService (pymavlink), and ReportService (SMTP + thread pools).

Assets & archives

S3 key layout, HLS m3u8 rewriting, thumbnails, and the two-phase delete.

VPN IP auth

How check_vpn_ip trusts 10.71.x source IPs and X-Drone-IP for SITL.

Drone actions

The control commands (arm, takeoff, missions) that bracket an execution.

On-drone uploader

The SkyCore side that calls these callbacks over the VPN.

Cross-system flows

The end-to-end execution + report path across repositories.