1use 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 pub live_count: i64,
48 pub in_flight_count: i64,
50 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 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 pub other_success_count: i64,
70 pub failed_count: i64,
71}
72
73#[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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct CreditRegistrationStepCount {
87 pub phase: TimelinePhase,
88 pub step: TimelineStep,
89 pub engagement: Option<Engagement>,
91 pub count: i64,
92}
93
94#[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#[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 pub is_known_phase: bool,
122 pub seconds_since_heartbeat: Option<i64>,
125 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 pub pending_by_reason: PendingReasonCounts,
136 pub error_codes: Vec<CreditRegistrationErrorCodeTotal>,
137 pub needs_attention_count: i64,
139 pub where_registrations_stand: Vec<CreditRegistrationStepCount>,
141 pub oldest_non_terminal: Option<CreditRegistrationOldestNonTerminal>,
142 pub throughput: Vec<CreditRegistrationThroughputBucket>,
143 pub throughput_days: i64,
144 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 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#[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#[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#[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#[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#[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
450async 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
475fn 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
488async 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
547pub(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}