Central Verification Dashboard

This commit is contained in:
2026-06-16 14:32:21 +05:30
commit 7cb85d5ebc
37 changed files with 9665 additions and 0 deletions

301
server/src/worker.rs Normal file
View File

@@ -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<WhoAmI> {
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<AppState>, user: AuthUser) -> ApiResult<WhoAmI> {
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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> ApiResult<ClaimResponse> {
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>)> =
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<i32> = 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> Result<Response, (StatusCode, String)> {
let row: Option<(String, String, Option<String>)> = 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> ApiResult<Value> {
let new_lease: Option<chrono::DateTime<chrono::Utc>> = 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> ApiResult<Value> {
let released: Option<i32> = 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(body): Json<Value>,
) -> ApiResult<Value> {
// 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<Option<String>> =
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<String, i32> = 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 })))
}