headless_lms_server/controllers/main_frontend/
status.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/status`.
2
3use crate::{
4    domain::authorization::{Action, Resource, authorize},
5    domain::system_health::{
6        self, HealthStatus, SystemHealthStatus, check_system_health_detailed, get_cronjobs,
7        get_deployments, get_events, get_ingresses, get_jobs, get_namespace,
8        get_pod_disruption_budgets, get_pod_logs, get_pods, get_services,
9    },
10    prelude::*,
11};
12use std::collections::HashMap;
13use utoipa::OpenApi;
14
15pub use system_health::{
16    CronJobInfo, DeploymentInfo, EventInfo, IngressInfo, JobInfo, PodDisruptionBudgetInfo, PodInfo,
17    ServiceInfo, ServicePortInfo,
18};
19
20#[derive(OpenApi)]
21#[openapi(paths(
22    pods,
23    deployments,
24    cronjobs,
25    jobs,
26    services,
27    events,
28    ingresses,
29    pod_disruption_budgets,
30    pod_logs,
31    health,
32    system_health
33))]
34pub(crate) struct MainFrontendStatusApiDoc;
35
36/**
37GET `/api/v0/main-frontend/status/pods`
38
39Returns the status of all Pods in the current namespace.
40*/
41#[utoipa::path(
42    get,
43    path = "/pods",
44    operation_id = "getStatusPods",
45    tag = "status",
46    responses(
47        (status = 200, description = "Pods", body = [PodInfo])
48    )
49)]
50pub async fn pods(
51    pool: web::Data<PgPool>,
52    user: AuthUser,
53) -> ControllerResult<web::Json<Vec<PodInfo>>> {
54    let mut conn = pool.acquire().await?;
55    let token = authorize(
56        &mut conn,
57        Action::Administrate,
58        Some(user.id),
59        Resource::GlobalPermissions,
60    )
61    .await?;
62    let ns = get_namespace();
63    let pods = get_pods(&ns).await.map_err(|e| {
64        ControllerError::new(
65            ControllerErrorType::InternalServerError,
66            e.to_string(),
67            None,
68        )
69    })?;
70    token.authorized_ok(web::Json(pods))
71}
72
73/**
74GET `/api/v0/main-frontend/status/deployments`
75
76Returns the status of all Deployments in the current namespace.
77*/
78#[utoipa::path(
79    get,
80    path = "/deployments",
81    operation_id = "getStatusDeployments",
82    tag = "status",
83    responses(
84        (status = 200, description = "Deployments", body = [DeploymentInfo])
85    )
86)]
87pub async fn deployments(
88    pool: web::Data<PgPool>,
89    user: AuthUser,
90) -> ControllerResult<web::Json<Vec<DeploymentInfo>>> {
91    let mut conn = pool.acquire().await?;
92    let token = authorize(
93        &mut conn,
94        Action::Administrate,
95        Some(user.id),
96        Resource::GlobalPermissions,
97    )
98    .await?;
99    let ns = get_namespace();
100    let deployments = get_deployments(&ns).await.map_err(|e| {
101        ControllerError::new(
102            ControllerErrorType::InternalServerError,
103            e.to_string(),
104            None,
105        )
106    })?;
107    token.authorized_ok(web::Json(deployments))
108}
109
110/**
111GET `/api/v0/main-frontend/status/cronjobs`
112
113Returns the status of all CronJobs in the current namespace.
114*/
115#[utoipa::path(
116    get,
117    path = "/cronjobs",
118    operation_id = "getStatusCronjobs",
119    tag = "status",
120    responses(
121        (status = 200, description = "Cronjobs", body = [CronJobInfo])
122    )
123)]
124pub async fn cronjobs(
125    pool: web::Data<PgPool>,
126    user: AuthUser,
127) -> ControllerResult<web::Json<Vec<CronJobInfo>>> {
128    let mut conn = pool.acquire().await?;
129    let token = authorize(
130        &mut conn,
131        Action::Administrate,
132        Some(user.id),
133        Resource::GlobalPermissions,
134    )
135    .await?;
136    let ns = get_namespace();
137    let cronjobs = get_cronjobs(&ns).await.map_err(|e| {
138        ControllerError::new(
139            ControllerErrorType::InternalServerError,
140            e.to_string(),
141            None,
142        )
143    })?;
144    token.authorized_ok(web::Json(cronjobs))
145}
146
147/**
148GET `/api/v0/main-frontend/status/jobs`
149
150Returns the status of all Jobs in the current namespace.
151*/
152#[utoipa::path(
153    get,
154    path = "/jobs",
155    operation_id = "getStatusJobs",
156    tag = "status",
157    responses(
158        (status = 200, description = "Jobs", body = [JobInfo])
159    )
160)]
161pub async fn jobs(
162    pool: web::Data<PgPool>,
163    user: AuthUser,
164) -> ControllerResult<web::Json<Vec<JobInfo>>> {
165    let mut conn = pool.acquire().await?;
166    let token = authorize(
167        &mut conn,
168        Action::Administrate,
169        Some(user.id),
170        Resource::GlobalPermissions,
171    )
172    .await?;
173    let ns = get_namespace();
174    let jobs = get_jobs(&ns).await.map_err(|e| {
175        ControllerError::new(
176            ControllerErrorType::InternalServerError,
177            e.to_string(),
178            None,
179        )
180    })?;
181    token.authorized_ok(web::Json(jobs))
182}
183
184/**
185GET `/api/v0/main-frontend/status/services`
186
187Returns the status of all Services in the current namespace.
188*/
189#[utoipa::path(
190    get,
191    path = "/services",
192    operation_id = "getStatusServices",
193    tag = "status",
194    responses(
195        (status = 200, description = "Services", body = [ServiceInfo])
196    )
197)]
198pub async fn services(
199    pool: web::Data<PgPool>,
200    user: AuthUser,
201) -> ControllerResult<web::Json<Vec<ServiceInfo>>> {
202    let mut conn = pool.acquire().await?;
203    let token = authorize(
204        &mut conn,
205        Action::Administrate,
206        Some(user.id),
207        Resource::GlobalPermissions,
208    )
209    .await?;
210    let ns = get_namespace();
211    let services = get_services(&ns).await.map_err(|e| {
212        ControllerError::new(
213            ControllerErrorType::InternalServerError,
214            e.to_string(),
215            None,
216        )
217    })?;
218    token.authorized_ok(web::Json(services))
219}
220
221/**
222GET `/api/v0/main-frontend/status/events`
223
224Returns the status of all Events in the current namespace.
225*/
226#[utoipa::path(
227    get,
228    path = "/events",
229    operation_id = "getStatusEvents",
230    tag = "status",
231    responses(
232        (status = 200, description = "Events", body = [EventInfo])
233    )
234)]
235pub async fn events(
236    pool: web::Data<PgPool>,
237    user: AuthUser,
238) -> ControllerResult<web::Json<Vec<EventInfo>>> {
239    let mut conn = pool.acquire().await?;
240    let token = authorize(
241        &mut conn,
242        Action::Administrate,
243        Some(user.id),
244        Resource::GlobalPermissions,
245    )
246    .await?;
247    let ns = get_namespace();
248    let events = get_events(&ns).await.map_err(|e| {
249        ControllerError::new(
250            ControllerErrorType::InternalServerError,
251            e.to_string(),
252            None,
253        )
254    })?;
255    token.authorized_ok(web::Json(events))
256}
257
258/**
259GET `/api/v0/main-frontend/status/ingresses`
260
261Returns the status of all Ingresses in the current namespace.
262*/
263#[utoipa::path(
264    get,
265    path = "/ingresses",
266    operation_id = "getStatusIngresses",
267    tag = "status",
268    responses(
269        (status = 200, description = "Ingresses", body = [IngressInfo])
270    )
271)]
272pub async fn ingresses(
273    pool: web::Data<PgPool>,
274    user: AuthUser,
275) -> ControllerResult<web::Json<Vec<IngressInfo>>> {
276    let mut conn = pool.acquire().await?;
277    let token = authorize(
278        &mut conn,
279        Action::Administrate,
280        Some(user.id),
281        Resource::GlobalPermissions,
282    )
283    .await?;
284    let ns = get_namespace();
285    let ingresses = get_ingresses(&ns).await.map_err(|e| {
286        ControllerError::new(
287            ControllerErrorType::InternalServerError,
288            e.to_string(),
289            None,
290        )
291    })?;
292    token.authorized_ok(web::Json(ingresses))
293}
294
295/**
296GET `/api/v0/main-frontend/status/pod-disruption-budgets`
297
298Returns the status of all PodDisruptionBudgets in the current namespace.
299*/
300#[utoipa::path(
301    get,
302    path = "/pod-disruption-budgets",
303    operation_id = "getStatusPodDisruptionBudgets",
304    tag = "status",
305    responses(
306        (status = 200, description = "Pod disruption budgets", body = [PodDisruptionBudgetInfo])
307    )
308)]
309pub async fn pod_disruption_budgets(
310    pool: web::Data<PgPool>,
311    user: AuthUser,
312) -> ControllerResult<web::Json<Vec<PodDisruptionBudgetInfo>>> {
313    let mut conn = pool.acquire().await?;
314    let token = authorize(
315        &mut conn,
316        Action::Administrate,
317        Some(user.id),
318        Resource::GlobalPermissions,
319    )
320    .await?;
321    let ns = get_namespace();
322    let pdbs = get_pod_disruption_budgets(&ns).await.map_err(|e| {
323        ControllerError::new(
324            ControllerErrorType::InternalServerError,
325            e.to_string(),
326            None,
327        )
328    })?;
329    token.authorized_ok(web::Json(pdbs))
330}
331
332fn parse_and_validate_tail(tail_str: Option<&String>) -> i64 {
333    const DEFAULT_TAIL_LINES: i64 = 1000;
334    const MAX_TAIL_LINES: i64 = 10_000;
335
336    match tail_str {
337        Some(s) => match s.parse::<u64>() {
338            Ok(val) => {
339                let clamped = val.min(MAX_TAIL_LINES as u64);
340                clamped as i64
341            }
342            Err(_) => DEFAULT_TAIL_LINES,
343        },
344        None => DEFAULT_TAIL_LINES,
345    }
346}
347
348/**
349GET `/api/v0/main-frontend/status/pods/{pod_name}/logs`
350
351Returns logs from a specific pod.
352
353Query parameters:
354- container: Optional<String> - Container name (if pod has multiple containers)
355- tail: Optional<u64> - Number of lines to tail from the end (default: 1000, max: 10000)
356*/
357#[utoipa::path(
358    get,
359    path = "/pods/{pod_name}/logs",
360    operation_id = "getStatusPodLogs",
361    tag = "status",
362    params(
363        ("pod_name" = String, Path, description = "Pod name"),
364        ("container" = Option<String>, Query, description = "Container name"),
365        ("tail" = Option<u64>, Query, description = "Number of log lines")
366    ),
367    responses(
368        (status = 200, description = "Pod logs", body = String)
369    )
370)]
371pub async fn pod_logs(
372    path: web::Path<String>,
373    query: web::Query<HashMap<String, String>>,
374    pool: web::Data<PgPool>,
375    user: AuthUser,
376) -> ControllerResult<HttpResponse> {
377    let mut conn = pool.acquire().await?;
378    let token = authorize(
379        &mut conn,
380        Action::Administrate,
381        Some(user.id),
382        Resource::GlobalPermissions,
383    )
384    .await?;
385    let pod_name = path.into_inner();
386    let ns = get_namespace();
387
388    let container = query.get("container").map(|s| s.as_str());
389    let tail = parse_and_validate_tail(query.get("tail"));
390
391    let logs = get_pod_logs(&ns, &pod_name, container, tail)
392        .await
393        .map_err(|e| {
394            ControllerError::new(
395                ControllerErrorType::InternalServerError,
396                e.to_string(),
397                None,
398            )
399        })?;
400
401    token.authorized_ok(
402        HttpResponse::Ok()
403            .content_type("text/plain; charset=utf-8")
404            .body(logs),
405    )
406}
407
408/**
409GET `/api/v0/main-frontend/status/health`
410
411Returns detailed system health status with issues list (admin only).
412*/
413#[utoipa::path(
414    get,
415    path = "/health",
416    operation_id = "getStatusHealth",
417    tag = "status",
418    responses(
419        (status = 200, description = "Detailed system health", body = SystemHealthStatus)
420    )
421)]
422pub async fn health(
423    pool: web::Data<PgPool>,
424    user: AuthUser,
425) -> ControllerResult<web::Json<SystemHealthStatus>> {
426    let mut conn = pool.acquire().await?;
427    let token = authorize(
428        &mut conn,
429        Action::Administrate,
430        Some(user.id),
431        Resource::GlobalPermissions,
432    )
433    .await?;
434    let ns = get_namespace();
435    let health_status = check_system_health_detailed(&ns, Some(&pool))
436        .await
437        .map_err(|e| {
438            ControllerError::new(
439                ControllerErrorType::InternalServerError,
440                e.to_string(),
441                None,
442            )
443        })?;
444    token.authorized_ok(web::Json(health_status))
445}
446
447/**
448GET `/api/v0/main-frontend/status/system-health` Returns a boolean indicating whether the system is healthy.
449
450Uses the same underlying checking logic as the detailed health endpoint.
451Unauthenticated users get a boolean. Authenticated admins get error details on failure.
452*/
453#[utoipa::path(
454    get,
455    path = "/system-health",
456    operation_id = "getStatusSystemHealth",
457    tag = "status",
458    responses(
459        (status = 200, description = "System health", body = bool)
460    )
461)]
462pub async fn system_health(
463    pool: web::Data<PgPool>,
464    user: Option<AuthUser>,
465) -> ControllerResult<web::Json<bool>> {
466    let ns = get_namespace();
467    let kubernetes_health = check_system_health_detailed(&ns, Some(&pool)).await;
468
469    let is_healthy = matches!(
470        kubernetes_health,
471        Ok(SystemHealthStatus {
472            status: HealthStatus::Healthy,
473            ..
474        })
475    );
476
477    if is_healthy {
478        let token = skip_authorize();
479        return token.authorized_ok(web::Json(true));
480    }
481
482    if let Some(user) = user {
483        let mut conn = pool.acquire().await?;
484        authorize(
485            &mut conn,
486            Action::Administrate,
487            Some(user.id),
488            Resource::GlobalPermissions,
489        )
490        .await?;
491
492        let error_msg = match kubernetes_health {
493            Err(e) => format!("System health check failed: {}", e),
494            Ok(health_status) => {
495                if health_status.issues.is_empty() {
496                    "System is unhealthy".to_string()
497                } else {
498                    format!("System is unhealthy: {}", health_status.issues.join(", "))
499                }
500            }
501        };
502
503        Err(ControllerError::new(
504            ControllerErrorType::InternalServerError,
505            error_msg,
506            None,
507        ))
508    } else {
509        let token = skip_authorize();
510        token.authorized_ok(web::Json(false))
511    }
512}
513
514pub fn _add_routes(cfg: &mut ServiceConfig) {
515    cfg.route("/pods", web::get().to(pods))
516        .route("/deployments", web::get().to(deployments))
517        .route("/cronjobs", web::get().to(cronjobs))
518        .route("/jobs", web::get().to(jobs))
519        .route("/services", web::get().to(services))
520        .route("/events", web::get().to(events))
521        .route("/ingresses", web::get().to(ingresses))
522        .route(
523            "/pod-disruption-budgets",
524            web::get().to(pod_disruption_budgets),
525        )
526        .route("/pods/{pod_name}/logs", web::get().to(pod_logs))
527        .route("/health", web::get().to(health))
528        .route("/system-health", web::get().to(system_health));
529}