Skip to main content

heldar_kernel/routes/
anr.rs

1//! ANR (Automatic Network Replenishment): persisted recording-gap listing + manual retry.
2//!
3//! Listing a camera's persisted recording gaps (the [`recording_gaps`](crate::models::RecordingGap)
4//! rows the indexer detects + the ANR loop fills) is open to any authenticated principal. Resetting a
5//! gap so the ANR loop retries it is a manager+ mutation and is written to the audit log.
6//!
7//! NOTE the path is `/recording-gaps` (not `/gaps`): `/api/v1/cameras/{id}/gaps`
8//! ([`crate::routes::recordings`]) already serves COMPUTED coverage holes over a time window. This
9//! surface exposes the PERSISTED gap table (with fill state) that ANR acts on — a distinct resource.
10
11use axum::extract::{Path, Query, State};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use serde::Deserialize;
15use serde_json::json;
16
17use crate::auth::{self, Principal};
18use crate::error::{AppError, AppResult};
19use crate::models::RecordingGap;
20use crate::routes::cameras::load_camera;
21use crate::state::AppState;
22
23pub fn router() -> Router<AppState> {
24    Router::new()
25        .route("/api/v1/cameras/{id}/recording-gaps", get(list_gaps))
26        .route(
27            "/api/v1/cameras/{id}/recording-gaps/{gap_id}/retry",
28            post(retry_gap),
29        )
30}
31
32#[derive(Debug, Deserialize)]
33struct GapQuery {
34    /// Optional filter on fill state (`pending` | `filled` | `failed`).
35    state: Option<String>,
36    limit: Option<i64>,
37}
38
39/// List a camera's persisted recording gaps, newest first (viewer+).
40async fn list_gaps(
41    State(st): State<AppState>,
42    Path(id): Path<String>,
43    principal: Principal,
44    Query(q): Query<GapQuery>,
45) -> AppResult<Json<Vec<RecordingGap>>> {
46    principal.require(principal.can_view(), "view recording gaps")?;
47    let _ = load_camera(&st.pool, &id).await?;
48    let limit = q.limit.unwrap_or(500).clamp(1, 5000);
49    let rows =
50        match q.state.as_deref() {
51            Some(state) => {
52                sqlx::query_as::<_, RecordingGap>(
53                    "SELECT * FROM recording_gaps WHERE camera_id = ? AND fill_state = ?
54             ORDER BY gap_start DESC LIMIT ?",
55                )
56                .bind(&id)
57                .bind(state)
58                .bind(limit)
59                .fetch_all(&st.pool)
60                .await?
61            }
62            None => sqlx::query_as::<_, RecordingGap>(
63                "SELECT * FROM recording_gaps WHERE camera_id = ? ORDER BY gap_start DESC LIMIT ?",
64            )
65            .bind(&id)
66            .bind(limit)
67            .fetch_all(&st.pool)
68            .await?,
69        };
70    Ok(Json(rows))
71}
72
73/// Reset a gap to `pending` (clearing attempts/result) so the ANR loop retries it (manager+).
74async fn retry_gap(
75    State(st): State<AppState>,
76    Path((id, gap_id)): Path<(String, String)>,
77    principal: Principal,
78) -> AppResult<Json<RecordingGap>> {
79    principal.require(principal.can_manage_registry(), "retry recording-gap fill")?;
80    let _ = load_camera(&st.pool, &id).await?;
81    let res = sqlx::query(
82        "UPDATE recording_gaps
83            SET fill_state = 'pending', fill_attempts = 0, last_attempt_at = NULL, filled_at = NULL
84          WHERE id = ? AND camera_id = ?",
85    )
86    .bind(&gap_id)
87    .bind(&id)
88    .execute(&st.pool)
89    .await?;
90    if res.rows_affected() == 0 {
91        return Err(AppError::NotFound(format!(
92            "recording gap {gap_id} not found"
93        )));
94    }
95    auth::audit(
96        &st.pool,
97        &principal,
98        "anr_retry_gap",
99        "recording_gap",
100        &gap_id,
101        json!({ "camera_id": id }),
102    )
103    .await;
104    let gap = sqlx::query_as::<_, RecordingGap>("SELECT * FROM recording_gaps WHERE id = ?")
105        .bind(&gap_id)
106        .fetch_one(&st.pool)
107        .await?;
108    Ok(Json(gap))
109}