Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
dashboard.rs

1//! The Overview tab, the Suotar health panel and phase pause/resume/run-now controls.
2
3use std::collections::HashMap;
4
5use headless_lms_base::config::ApplicationConfiguration;
6use headless_lms_models::credit_registration_admin_actions::{
7    CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
8    NewCreditRegistrationAdminAction,
9};
10use headless_lms_models::credit_registration_phase_state;
11use headless_lms_models::credit_registrations::{
12    self, AttentionStanding, CreditRegistrationErrorCode, CreditRegistrationErrorCodeCount,
13    CreditRegistrationState, OldestNonTerminalRegistration, StepCount, StuckRegistrationCount,
14};
15use headless_lms_models::library::credit_registration::PendingReasonCounts;
16use headless_lms_models::library::credit_registration::timeline::{
17    Engagement, TimelinePhase, TimelineStep,
18};
19use headless_lms_models::suotar_api_calls::{
20    self, SuotarEndpoint, SuotarEndpointStanding as SuotarEndpointStandingRow,
21    SuotarEndpointStatsForWindow,
22};
23use utoipa::ToSchema;
24
25use crate::domain::credit_registration::health::{
26    CreditRegistrationHealth, evaluate, is_heartbeat_late,
27};
28use crate::prelude::*;
29use headless_lms_credit_registration::CreditRegistrationPhase;
30
31use super::{attention_rules, authorize_credit_registration_admin, required_reason};
32
33const THROUGHPUT_DAYS: i64 = 30;
34
35const ENDPOINT_STATS_WINDOWS_SECS: [i64; 3] = [60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60];
36
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
38pub struct CreditRegistrationStateTotal {
39    pub state: CreditRegistrationState,
40    pub count: i64,
41}
42
43#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
44pub struct CreditRegistrationErrorCodeTotal {
45    pub error_code: CreditRegistrationErrorCode,
46    /// Every live row carrying the code.
47    pub live_count: i64,
48    /// Rows the pipeline is still working on.
49    pub in_flight_count: i64,
50    /// Rows that ended on this code.
51    pub terminal_failure_count: i64,
52}
53
54#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
55pub struct CreditRegistrationOldestNonTerminal {
56    pub credit_registration_id: Uuid,
57    pub state: CreditRegistrationState,
58    pub state_changed_at: DateTime<Utc>,
59    /// Computed server-side: a page comparing its own clock against a server timestamp misjudges
60    /// this on a skewed client, the same reason `seconds_since_heartbeat` is computed here too.
61    pub seconds_in_state: i64,
62}
63
64#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
65pub struct CreditRegistrationThroughputBucket {
66    pub day: DateTime<Utc>,
67    pub registered_count: i64,
68    /// `duplicate` and `not_improved`: the credit exists, and we did not put it there.
69    pub other_success_count: i64,
70    pub failed_count: i64,
71}
72
73/// The Running late rows in one state.
74#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
75pub struct CreditRegistrationStuckTotal {
76    pub state: CreditRegistrationState,
77    pub count: i64,
78    pub severely_stuck_count: i64,
79    pub oldest_state_changed_at: Option<DateTime<Utc>>,
80}
81
82/// Live registrations at one timeline step, split by engagement on the steps that wait on the
83/// student. Equal to the Registrations list filtered by `step` and `engagement` (and
84/// `course_module_id` where the count is per module).
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct CreditRegistrationStepCount {
87    pub phase: TimelinePhase,
88    pub step: TimelineStep,
89    /// `None` on a step that does not wait on the student.
90    pub engagement: Option<Engagement>,
91    pub count: i64,
92}
93
94/// Where one study registry endpoint stands, over all time.
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96pub struct SuotarEndpointStanding {
97    pub endpoint: SuotarEndpoint,
98    pub last_success_at: Option<DateTime<Utc>>,
99    pub last_failure_at: Option<DateTime<Utc>>,
100    pub consecutive_failures: i64,
101}
102
103/// One pipeline phase's heartbeat, written by the worker loops and by unscoped runs only, never by a
104/// narrowed one. Returned by the pause/resume/run-now actions; the Workers tab lists
105/// `CreditRegistrationPhaseRow` instead, which is wider.
106#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
107pub struct CreditRegistrationPhaseStatus {
108    pub phase: String,
109    pub process_name: String,
110    pub expected_interval_secs: i32,
111    pub last_heartbeat_at: Option<DateTime<Utc>>,
112    pub last_success_at: Option<DateTime<Utc>>,
113    pub last_run_finished_at: Option<DateTime<Utc>>,
114    pub items_processed_last_run: Option<i32>,
115    pub items_failed_last_run: Option<i32>,
116    pub consecutive_failures: i32,
117    pub paused_at: Option<DateTime<Utc>>,
118    pub pause_reason: Option<String>,
119    /// False only for a phase-state row whose name is no `CreditRegistrationPhase`, which no worker
120    /// runs or reports for.
121    pub is_known_phase: bool,
122    /// Computed server-side: a page comparing its own clock against a server timestamp misjudges this
123    /// on a skewed client.
124    pub seconds_since_heartbeat: Option<i64>,
125    /// `seconds_since_heartbeat > expected_interval_secs * health.thresholds.phase_heartbeat_interval_multiplier`.
126    /// Always `false` while paused or never heartbeated.
127    pub heartbeat_late: bool,
128}
129
130#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
131pub struct CreditRegistrationOverview {
132    pub health: CreditRegistrationHealth,
133    pub counts_by_state: Vec<CreditRegistrationStateTotal>,
134    /// The `pending` depth split by what each row is waiting on, which the ledger does not store.
135    pub pending_by_reason: PendingReasonCounts,
136    pub error_codes: Vec<CreditRegistrationErrorCodeTotal>,
137    /// The Needs attention count: the same number as `/attention`'s `total_count` and the tab badge.
138    pub needs_attention_count: i64,
139    /// "Where registrations stand": every step with live rows, Not started left out.
140    pub where_registrations_stand: Vec<CreditRegistrationStepCount>,
141    pub oldest_non_terminal: Option<CreditRegistrationOldestNonTerminal>,
142    pub throughput: Vec<CreditRegistrationThroughputBucket>,
143    pub throughput_days: i64,
144    /// The Running late rows per state, which the stuck-registrations alert counts.
145    pub stuck: Vec<CreditRegistrationStuckTotal>,
146    pub endpoints: Vec<SuotarEndpointStanding>,
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
150pub struct SuotarEndpointWindowStats {
151    pub endpoint: SuotarEndpoint,
152    pub call_count: i64,
153    pub failed_call_count: i64,
154    pub in_flight_count: i64,
155    pub ok_item_count: i64,
156    pub error_item_count: i64,
157    pub pending_item_count: i64,
158    pub p50_duration_ms: Option<i32>,
159    pub p95_duration_ms: Option<i32>,
160    pub last_success_at: Option<DateTime<Utc>>,
161    pub last_failure_at: Option<DateTime<Utc>>,
162    /// The registry's own request-level code, an identifier rather than prose.
163    pub last_request_level_error_code: Option<String>,
164}
165
166#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
167pub struct SuotarHealthWindow {
168    pub window_secs: i64,
169    pub endpoints: Vec<SuotarEndpointWindowStats>,
170}
171
172#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
173pub struct SuotarHealth {
174    pub windows: Vec<SuotarHealthWindow>,
175}
176
177#[derive(Debug, Deserialize, ToSchema)]
178pub struct AdminPausePhasePayload {
179    pub reason: String,
180}
181
182#[derive(Debug, Deserialize, ToSchema)]
183pub struct AdminPhaseActionPayload {
184    pub reason: Option<String>,
185}
186
187/**
188GET `/api/v0/main-frontend/credit-registration-admin/overview` - Everything the Overview tab and the
189alert banner render, in one request so the tiles cannot contradict each other.
190*/
191#[instrument(skip(pool, app_conf))]
192#[utoipa::path(
193    get,
194    path = "/overview",
195    operation_id = "getCreditRegistrationOverview",
196    tag = "credit-registration-admin",
197    responses(
198        (status = 200, description = "Counts, throughput, phase heartbeats and the active alerts", body = CreditRegistrationOverview)
199    )
200)]
201pub async fn get_credit_registration_overview(
202    user: AuthUser,
203    pool: web::Data<PgPool>,
204    app_conf: web::Data<ApplicationConfiguration>,
205) -> ControllerResult<web::Json<CreditRegistrationOverview>> {
206    let mut conn = pool.acquire().await?;
207    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
208
209    let rules = attention_rules(&mut conn, &app_conf).await?;
210    let attention_rows = credit_registrations::get_attention_items(&mut conn, &rules, None).await?;
211    let stuck_rows =
212        credit_registrations::count_stuck_in_state(&attention_rows, &rules.thresholds, Utc::now());
213    let needs_attention_count = attention_rows
214        .iter()
215        .filter(|row| row.standing(&rules.blocking) == AttentionStanding::NeedsAttention)
216        .count() as i64;
217    let where_registrations_stand = sum_step_counts(
218        credit_registrations::count_by_step_and_engagement(
219            &mut conn,
220            app_conf.suotar_configuration.account_linking_since,
221        )
222        .await?
223        .iter(),
224    );
225    let depths = credit_registrations::count_by_state(&mut conn).await?;
226    let health = evaluate(&mut conn, &stuck_rows, &depths).await?;
227    let counts_by_state = depths
228        .iter()
229        .map(|&(state, count)| CreditRegistrationStateTotal { state, count })
230        .collect();
231    let pending_by_reason = credit_registrations::count_pending_by_reason(&mut conn).await?;
232    let error_codes = credit_registrations::count_by_error_code(&mut conn)
233        .await?
234        .into_iter()
235        .map(to_error_code_total)
236        .collect();
237    let oldest_non_terminal = credit_registrations::get_oldest_non_terminal(&mut conn)
238        .await?
239        .map(|row| to_oldest_non_terminal(row, Utc::now()));
240    let throughput = credit_registrations::get_throughput_by_day(
241        &mut conn,
242        Utc::now() - chrono::Duration::days(THROUGHPUT_DAYS),
243    )
244    .await?
245    .into_iter()
246    .map(|row| CreditRegistrationThroughputBucket {
247        day: row.day,
248        registered_count: row.registered_count,
249        other_success_count: row.other_success_count,
250        failed_count: row.failed_count,
251    })
252    .collect();
253    let stuck = stuck_rows.into_iter().map(to_stuck_total).collect();
254    let endpoints = suotar_api_calls::get_endpoint_standings(&mut conn)
255        .await?
256        .into_iter()
257        .map(to_endpoint_standing)
258        .collect();
259
260    token.authorized_ok(web::Json(CreditRegistrationOverview {
261        health,
262        counts_by_state,
263        pending_by_reason,
264        error_codes,
265        needs_attention_count,
266        where_registrations_stand,
267        oldest_non_terminal,
268        throughput,
269        throughput_days: THROUGHPUT_DAYS,
270        stuck,
271        endpoints,
272    }))
273}
274
275/**
276GET `/api/v0/main-frontend/credit-registration-admin/suotar-health` - Per-endpoint call counts,
277success rates and latency percentiles over an hour, a day and a week.
278*/
279#[instrument(skip(pool))]
280#[utoipa::path(
281    get,
282    path = "/suotar-health",
283    operation_id = "getSuotarHealth",
284    tag = "credit-registration-admin",
285    responses(
286        (status = 200, description = "Study registry traffic per endpoint and window", body = SuotarHealth)
287    )
288)]
289pub async fn get_suotar_health(
290    user: AuthUser,
291    pool: web::Data<PgPool>,
292) -> ControllerResult<web::Json<SuotarHealth>> {
293    let mut conn = pool.acquire().await?;
294    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
295
296    let mut by_window: HashMap<i64, Vec<SuotarEndpointWindowStats>> = HashMap::new();
297    for row in
298        suotar_api_calls::get_endpoint_stats_for_windows(&mut conn, &ENDPOINT_STATS_WINDOWS_SECS)
299            .await?
300    {
301        by_window
302            .entry(row.window_secs)
303            .or_default()
304            .push(to_endpoint_window_stats_for_window(row));
305    }
306    let windows = ENDPOINT_STATS_WINDOWS_SECS
307        .into_iter()
308        .map(|window_secs| SuotarHealthWindow {
309            window_secs,
310            endpoints: by_window.remove(&window_secs).unwrap_or_default(),
311        })
312        .collect();
313
314    token.authorized_ok(web::Json(SuotarHealth { windows }))
315}
316
317/**
318POST `/api/v0/main-frontend/credit-registration-admin/phases/{phase}/pause` - Pauses one phase: the
319worker loop skips it on every tick until it is resumed.
320*/
321#[instrument(skip(pool, payload))]
322#[utoipa::path(
323    post,
324    path = "/phases/{phase}/pause",
325    operation_id = "adminPausePhase",
326    tag = "credit-registration-admin",
327    params(("phase" = String, Path, description = "A canonical phase name")),
328    request_body = AdminPausePhasePayload,
329    responses(
330        (status = 200, description = "The phase's status after pausing", body = CreditRegistrationPhaseStatus),
331        (status = 422, description = "No reason given, or not one of the canonical phase names")
332    )
333)]
334pub async fn admin_pause_phase(
335    user: AuthUser,
336    pool: web::Data<PgPool>,
337    phase: web::Path<String>,
338    payload: web::Json<AdminPausePhasePayload>,
339) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
340    let mut conn = pool.acquire().await?;
341    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
342
343    let phase = require_known_phase(&phase)?;
344    let reason = required_reason(&payload.reason)?;
345
346    info!(phase, actor = %user.id, "Admin paused credit registration phase");
347    let mut tx = conn.begin().await?;
348    credit_registration_phase_state::pause(&mut tx, phase, user.id, Some(reason)).await?;
349    record_phase_action(
350        &mut tx,
351        phase,
352        CreditRegistrationAdminAction::PausePhase,
353        user.id,
354        Some(reason.to_string()),
355    )
356    .await?;
357    tx.commit().await?;
358
359    token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
360}
361
362/**
363POST `/api/v0/main-frontend/credit-registration-admin/phases/{phase}/resume` - Resumes one paused
364phase.
365*/
366#[instrument(skip(pool, payload))]
367#[utoipa::path(
368    post,
369    path = "/phases/{phase}/resume",
370    operation_id = "adminResumePhase",
371    tag = "credit-registration-admin",
372    params(("phase" = String, Path, description = "A canonical phase name")),
373    request_body = AdminPhaseActionPayload,
374    responses(
375        (status = 200, description = "The phase's status after resuming", body = CreditRegistrationPhaseStatus),
376        (status = 422, description = "Not one of the canonical phase names")
377    )
378)]
379pub async fn admin_resume_phase(
380    user: AuthUser,
381    pool: web::Data<PgPool>,
382    phase: web::Path<String>,
383    payload: web::Json<AdminPhaseActionPayload>,
384) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
385    let mut conn = pool.acquire().await?;
386    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
387
388    let phase = require_known_phase(&phase)?;
389
390    info!(phase, actor = %user.id, "Admin resumed credit registration phase");
391    let mut tx = conn.begin().await?;
392    credit_registration_phase_state::resume(&mut tx, phase).await?;
393    record_phase_action(
394        &mut tx,
395        phase,
396        CreditRegistrationAdminAction::ResumePhase,
397        user.id,
398        payload.reason.clone(),
399    )
400    .await?;
401    tx.commit().await?;
402
403    token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
404}
405
406/**
407POST `/api/v0/main-frontend/credit-registration-admin/phases/{phase}/run-now` - Makes one phase due
408immediately: the worker loop picks it up on its next tick instead of waiting out `next_run_at`.
409*/
410#[instrument(skip(pool, payload))]
411#[utoipa::path(
412    post,
413    path = "/phases/{phase}/run-now",
414    operation_id = "adminRunPhaseNow",
415    tag = "credit-registration-admin",
416    params(("phase" = String, Path, description = "A canonical phase name")),
417    request_body = AdminPhaseActionPayload,
418    responses(
419        (status = 200, description = "The phase's status after being made due", body = CreditRegistrationPhaseStatus),
420        (status = 422, description = "Not one of the canonical phase names")
421    )
422)]
423pub async fn admin_run_phase_now(
424    user: AuthUser,
425    pool: web::Data<PgPool>,
426    phase: web::Path<String>,
427    payload: web::Json<AdminPhaseActionPayload>,
428) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
429    let mut conn = pool.acquire().await?;
430    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
431
432    let phase = require_known_phase(&phase)?;
433
434    info!(phase, actor = %user.id, "Admin forced credit registration phase to run now");
435    let mut tx = conn.begin().await?;
436    credit_registration_phase_state::run_now(&mut tx, phase).await?;
437    record_phase_action(
438        &mut tx,
439        phase,
440        CreditRegistrationAdminAction::RunPhaseNow,
441        user.id,
442        payload.reason.clone(),
443    )
444    .await?;
445    tx.commit().await?;
446
447    token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
448}
449
450/// Records one admin action on a phase in the caller's transaction.
451async fn record_phase_action(
452    tx: &mut PgConnection,
453    phase: &str,
454    action: CreditRegistrationAdminAction,
455    actor_user_id: Uuid,
456    reason: Option<String>,
457) -> Result<(), ControllerError> {
458    models::credit_registration_admin_actions::record(
459        tx,
460        &NewCreditRegistrationAdminAction {
461            target_phase: Some(phase.to_string()),
462            reason,
463            ..NewCreditRegistrationAdminAction::new(
464                action,
465                CreditRegistrationAdminActionTarget::Phase,
466                actor_user_id,
467                GLOBAL_ADMIN_ROLE,
468            )
469        },
470    )
471    .await?;
472    Ok(())
473}
474
475/// Resolves a path segment to the spelling `credit_registration_phase_state` stores, refusing anything
476/// that is not a canonical phase name.
477fn require_known_phase(phase: &str) -> Result<&'static str, ControllerError> {
478    CreditRegistrationPhase::from_phase_name(phase)
479        .map(CreditRegistrationPhase::as_str)
480        .ok_or_else(|| {
481            controller_err!(
482                BadRequest,
483                "Not one of the canonical phase names.".to_string()
484            )
485        })
486}
487
488/// One phase's status, so a pause/resume/run-now response shows the effect without a second request.
489async fn one_phase_status(
490    conn: &mut PgConnection,
491    phase: &str,
492) -> Result<CreditRegistrationPhaseStatus, ControllerError> {
493    let row = credit_registration_phase_state::get_by_phase(conn, phase).await?;
494    Ok(to_phase_status(row, Utc::now()))
495}
496
497pub(super) fn to_phase_status(
498    row: credit_registration_phase_state::CreditRegistrationPhaseState,
499    now: DateTime<Utc>,
500) -> CreditRegistrationPhaseStatus {
501    let seconds_since_heartbeat = row.last_heartbeat_at.map(|at| (now - at).num_seconds());
502    let heartbeat_late = is_heartbeat_late(
503        row.last_heartbeat_at,
504        row.expected_interval_secs,
505        row.paused_at,
506        now,
507    );
508    CreditRegistrationPhaseStatus {
509        is_known_phase: CreditRegistrationPhase::from_phase_name(&row.phase).is_some(),
510        phase: row.phase,
511        process_name: row.process_name,
512        expected_interval_secs: row.expected_interval_secs,
513        last_heartbeat_at: row.last_heartbeat_at,
514        last_success_at: row.last_success_at,
515        last_run_finished_at: row.last_run_finished_at,
516        items_processed_last_run: row.items_processed_last_run,
517        items_failed_last_run: row.items_failed_last_run,
518        consecutive_failures: row.consecutive_failures,
519        paused_at: row.paused_at,
520        pause_reason: row.pause_reason,
521        seconds_since_heartbeat,
522        heartbeat_late,
523    }
524}
525
526fn to_error_code_total(row: CreditRegistrationErrorCodeCount) -> CreditRegistrationErrorCodeTotal {
527    CreditRegistrationErrorCodeTotal {
528        error_code: row.error_code,
529        live_count: row.live_count,
530        in_flight_count: row.in_flight_count,
531        terminal_failure_count: row.terminal_failure_count,
532    }
533}
534
535fn to_oldest_non_terminal(
536    row: OldestNonTerminalRegistration,
537    now: DateTime<Utc>,
538) -> CreditRegistrationOldestNonTerminal {
539    CreditRegistrationOldestNonTerminal {
540        credit_registration_id: row.id,
541        state: row.state,
542        seconds_in_state: (now - row.state_changed_at).num_seconds(),
543        state_changed_at: row.state_changed_at,
544    }
545}
546
547/// Sums per-module counts into one per step and engagement, Not started left out, in timeline order.
548pub(super) fn sum_step_counts<'a>(
549    rows: impl IntoIterator<Item = &'a StepCount>,
550) -> Vec<CreditRegistrationStepCount> {
551    credit_registrations::sum_step_counts_over_modules(rows)
552        .into_iter()
553        .filter(|((_, engagement), _)| *engagement != Some(Engagement::NotStarted))
554        .map(|((step, engagement), count)| CreditRegistrationStepCount {
555            phase: step.phase(),
556            step,
557            engagement,
558            count,
559        })
560        .collect()
561}
562
563fn to_stuck_total(row: StuckRegistrationCount) -> CreditRegistrationStuckTotal {
564    CreditRegistrationStuckTotal {
565        state: row.state,
566        count: row.count,
567        severely_stuck_count: row.severely_stuck_count,
568        oldest_state_changed_at: row.oldest_state_changed_at,
569    }
570}
571
572fn to_endpoint_standing(row: SuotarEndpointStandingRow) -> SuotarEndpointStanding {
573    SuotarEndpointStanding {
574        endpoint: row.endpoint,
575        last_success_at: row.last_success_at,
576        last_failure_at: row.last_failure_at,
577        consecutive_failures: row.consecutive_failures,
578    }
579}
580
581fn to_endpoint_window_stats_for_window(
582    row: SuotarEndpointStatsForWindow,
583) -> SuotarEndpointWindowStats {
584    SuotarEndpointWindowStats {
585        endpoint: row.endpoint,
586        call_count: row.call_count,
587        failed_call_count: row.failed_call_count,
588        in_flight_count: row.in_flight_count,
589        ok_item_count: row.ok_item_count,
590        error_item_count: row.error_item_count,
591        pending_item_count: row.pending_item_count,
592        p50_duration_ms: row.p50_duration_ms,
593        p95_duration_ms: row.p95_duration_ms,
594        last_success_at: row.last_success_at,
595        last_failure_at: row.last_failure_at,
596        last_request_level_error_code: row.last_request_level_error_code,
597    }
598}
599
600pub fn _add_routes(cfg: &mut ServiceConfig) {
601    cfg.route("/overview", web::get().to(get_credit_registration_overview))
602        .route("/suotar-health", web::get().to(get_suotar_health))
603        .route("/phases/{phase}/pause", web::post().to(admin_pause_phase))
604        .route("/phases/{phase}/resume", web::post().to(admin_resume_phase))
605        .route(
606            "/phases/{phase}/run-now",
607            web::post().to(admin_run_phase_now),
608        );
609}