992 lines
41 KiB
Rust
992 lines
41 KiB
Rust
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<T> = Result<Json<T>, (StatusCode, String)>;
|
|
pub fn err<E: std::fmt::Display>(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 <a download>, 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<AppState>, user: AuthUser) -> ApiResult<StorageInfo> {
|
|
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<u64, sqlx::Error> {
|
|
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<u64, sqlx::Error> {
|
|
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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<TimerRequest>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
user.require_admin()?;
|
|
let exists: Option<i32> = 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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<FolderRow>> {
|
|
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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<FoldersRequest>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
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<PgPool> {
|
|
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<bool, (StatusCode, String)> {
|
|
if user.is_admin() {
|
|
return Ok(true);
|
|
}
|
|
let m: Option<i32> = 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<AppState>, user: AuthUser) -> ApiResult<Vec<ProjectSummary>> {
|
|
// Non-admins only see projects they're a member of (picked by an admin).
|
|
let rows: Vec<(i32, String, String, String, Option<i32>, 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<AppState>, _user: AuthUser) -> ApiResult<Vec<ClientRow>> {
|
|
let rows: Vec<(i32, String, chrono::DateTime<chrono::Utc>, 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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
// 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<i64>,
|
|
}
|
|
|
|
/// Query-param auth for file downloads (`<a download>` can't send an auth header).
|
|
#[derive(serde::Deserialize)]
|
|
struct TokenQuery {
|
|
token: Option<String>,
|
|
}
|
|
|
|
/// `GET /api/audit?limit=100` — recent org-level audit events (auth required).
|
|
async fn list_audit(State(s): State<AppState>, _user: AuthUser, Query(q): Query<AuditQuery>) -> ApiResult<Vec<AuditRow>> {
|
|
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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<VideoRow>> {
|
|
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<AppState>, Query(q): Query<TokenQuery>, AxPath(id): AxPath<i32>) -> Result<Response, (StatusCode, String)> {
|
|
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<serde_json::Value>)> =
|
|
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<AppState>, Query(q): Query<TokenQuery>, AxPath(id): AxPath<i32>) -> Result<Response, (StatusCode, String)> {
|
|
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<serde_json::Value>)> = 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<serde_json::Value> = 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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<AssignRequest>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
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<String> = 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<String> = 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<AppState>, _user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<Remark>> {
|
|
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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<NewRemarkRequest>,
|
|
) -> ApiResult<Vec<Remark>> {
|
|
let body = req.body.trim();
|
|
if body.is_empty() {
|
|
return Err((StatusCode::BAD_REQUEST, "remark is required".into()));
|
|
}
|
|
let exists: Option<i32> = 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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<HandRequest>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
let updated: Option<i32> = 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<AppState>,
|
|
user: AuthUser,
|
|
AxPath(id): AxPath<i32>,
|
|
Json(req): Json<IgnoreRequest>,
|
|
) -> ApiResult<serde_json::Value> {
|
|
user.require_admin()?;
|
|
let by: Option<String> = if req.ignored { Some(user.username.clone()) } else { None };
|
|
let updated: Option<i32> = 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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Stats> {
|
|
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 <> '' AND lower(a.annotated_by) <> 'auto'
|
|
GROUP BY a.annotated_by"#,
|
|
)
|
|
.bind(id)
|
|
.fetch_all(&s.db)
|
|
.await
|
|
.map_err(err)?;
|
|
let mut leaderboard: Vec<LeaderRow> = 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<String, i64> =
|
|
leaderboard.iter().map(|r| (r.user.clone(), r.annotations)).collect();
|
|
|
|
// Verification leaderboard: ALL assigned users (members) ranked by ANNOTATION
|
|
// THROUGHPUT — annotations added per minute of work (most annotations in the least
|
|
// time first), not by video count. Anyone who has completed videos but isn't a
|
|
// member is included too; users with no annotations/time sort last (unranked).
|
|
// The synthetic 'auto' author (auto-segmented/imported) is excluded. 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 <> '' AND lower(completed_by) <> 'auto'
|
|
GROUP BY completed_by"#,
|
|
)
|
|
.bind(id)
|
|
.fetch_all(&s.db)
|
|
.await
|
|
.map_err(err)?;
|
|
let members: Vec<String> = 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<String, (i64, i64)> =
|
|
ver_rows.into_iter().map(|(u, v, t)| (u, (v, t))).collect();
|
|
for m in &members {
|
|
if m.eq_ignore_ascii_case("auto") {
|
|
continue;
|
|
}
|
|
stats_by_user.entry(m.clone()).or_insert((0, 0));
|
|
}
|
|
let mut verifiers: Vec<VerifierRow> = stats_by_user
|
|
.into_iter()
|
|
.map(|(user, (videos, total_time_ms))| {
|
|
let annotations = ann_by_user.get(&user).copied().unwrap_or(0);
|
|
// annotations added per minute of work; 0 when no time recorded yet.
|
|
let annotations_per_min = if total_time_ms > 0 {
|
|
annotations as f64 / (total_time_ms as f64 / 60_000.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
VerifierRow {
|
|
avg_time_ms: if videos > 0 { total_time_ms / videos } else { 0 },
|
|
videos,
|
|
total_time_ms,
|
|
annotations,
|
|
annotations_per_min,
|
|
rank: 0,
|
|
user,
|
|
}
|
|
})
|
|
.collect();
|
|
// Highest throughput first (most annotations per minute). A user is rankable only
|
|
// once they have both annotations AND recorded time; everyone else sorts last by name.
|
|
let rankable = |v: &VerifierRow| v.annotations > 0 && v.annotations_per_min > 0.0;
|
|
verifiers.sort_by(|a, b| match (rankable(a), rankable(b)) {
|
|
(true, false) => std::cmp::Ordering::Less,
|
|
(false, true) => std::cmp::Ordering::Greater,
|
|
(true, true) => b
|
|
.annotations_per_min
|
|
.partial_cmp(&a.annotations_per_min)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
.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 rankable(v) {
|
|
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<chrono::DateTime<chrono::Utc>>,
|
|
Option<chrono::DateTime<chrono::Utc>>,
|
|
) = 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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Activity> {
|
|
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 }))
|
|
}
|