1use std::collections::HashMap;
11use std::sync::{Arc, RwLock};
12
13use axum::Json;
14use axum::extract::{Path, State};
15use axum::http::StatusCode;
16use axum::response::IntoResponse;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Default)]
25pub struct A11yPosture {
26 pub lang_set: bool,
28 pub skip_link_present: bool,
30 pub landmark_regions_present: bool,
33}
34
35impl A11yPosture {
36 #[must_use]
38 pub const fn is_compliant(&self) -> bool {
39 self.lang_set && self.skip_link_present && self.landmark_regions_present
40 }
41}
42
43pub trait ProvideActuatorState {
49 fn metrics(&self) -> &crate::middleware::MetricsCollector;
52
53 fn log_levels(&self) -> &LogLevels;
56
57 fn task_registry(&self) -> &TaskRegistry;
60
61 fn job_registry(&self) -> &JobRegistry;
64
65 fn config_props(&self) -> &ConfigProperties;
68
69 fn profile(&self) -> &str;
72
73 fn uptime_display(&self) -> String;
76
77 #[cfg(feature = "ws")]
80 fn channels(&self) -> &crate::channels::Channels;
81
82 #[cfg(feature = "ws")]
84 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
85
86 #[cfg(feature = "db")]
89 fn pool(
90 &self,
91 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>>;
92
93 fn a11y_posture(&self) -> A11yPosture {
99 A11yPosture::default()
100 }
101}
102
103#[derive(Clone)]
110pub struct LogLevels {
111 inner: Arc<RwLock<LogLevelsInner>>,
112}
113
114struct LogLevelsInner {
115 current_level: String,
117 logger_overrides: HashMap<String, String>,
119}
120
121impl LogLevels {
122 #[must_use]
124 pub fn new(initial_level: &str) -> Self {
125 Self {
126 inner: Arc::new(RwLock::new(LogLevelsInner {
127 current_level: initial_level.to_string(),
128 logger_overrides: HashMap::new(),
129 })),
130 }
131 }
132
133 #[must_use]
135 pub fn current_level(&self) -> String {
136 self.inner
137 .read()
138 .map_or_else(|_| "info".to_string(), |guard| guard.current_level.clone())
139 }
140
141 #[must_use]
143 pub fn logger_overrides(&self) -> HashMap<String, String> {
144 self.inner
145 .read()
146 .map(|guard| guard.logger_overrides.clone())
147 .unwrap_or_default()
148 }
149
150 #[must_use]
152 pub fn set_logger_level(&self, name: &str, level: &str) -> Option<String> {
153 let Ok(mut guard) = self.inner.write() else {
154 return None;
155 };
156 if guard.logger_overrides.len() >= 1000 && !guard.logger_overrides.contains_key(name) {
158 return None;
159 }
160
161 let previous = guard.logger_overrides.get(name).cloned();
162 guard
163 .logger_overrides
164 .insert(name.to_string(), level.to_string());
165 if name == "root" || name.is_empty() {
167 let prev = Some(guard.current_level.clone());
168 guard.current_level = level.to_string();
169 return prev;
170 }
171 previous
172 }
173}
174
175impl std::fmt::Debug for LogLevels {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 f.debug_struct("LogLevels")
178 .field("current_level", &self.current_level())
179 .finish()
180 }
181}
182
183#[derive(Debug, Clone, Serialize)]
185pub struct TaskStatus {
186 pub schedule: String,
188 pub coordination: crate::task::TaskCoordination,
190 pub scheduler_backend: String,
192 pub replica_id: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub current_leader: Option<String>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub last_tick: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub last_fired_at: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub next_run_at: Option<String>,
206 pub status: String,
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub last_run: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub last_duration_ms: Option<u64>,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub last_result: Option<String>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub last_error: Option<String>,
220 pub total_runs: u64,
222 pub total_failures: u64,
224}
225
226#[derive(Clone)]
228pub struct TaskRegistry {
229 inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
230}
231
232#[derive(Debug, Clone, Serialize)]
234pub struct JobStatus {
235 pub queued: u64,
237 pub in_flight: u64,
239 pub total_successes: u64,
241 pub total_failures: u64,
243 pub dead_letters: u64,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub last_error: Option<String>,
248}
249
250#[derive(Clone)]
252pub struct JobRegistry {
253 inner: Arc<RwLock<HashMap<String, JobStatus>>>,
254}
255
256impl JobRegistry {
257 #[must_use]
259 pub fn new() -> Self {
260 Self {
261 inner: Arc::new(RwLock::new(HashMap::new())),
262 }
263 }
264
265 pub fn register(&self, name: &str) {
267 if let Ok(mut guard) = self.inner.write() {
268 guard.entry(name.to_string()).or_insert(JobStatus {
269 queued: 0,
270 in_flight: 0,
271 total_successes: 0,
272 total_failures: 0,
273 dead_letters: 0,
274 last_error: None,
275 });
276 }
277 }
278
279 pub fn record_enqueue(&self, name: &str) {
281 if let Ok(mut guard) = self.inner.write() {
282 let status = guard.entry(name.to_string()).or_insert(JobStatus {
283 queued: 0,
284 in_flight: 0,
285 total_successes: 0,
286 total_failures: 0,
287 dead_letters: 0,
288 last_error: None,
289 });
290 status.queued = status.queued.saturating_add(1);
291 }
292 }
293
294 pub fn record_start(&self, name: &str) {
296 if let Ok(mut guard) = self.inner.write()
297 && let Some(status) = guard.get_mut(name)
298 {
299 status.queued = status.queued.saturating_sub(1);
300 status.in_flight = status.in_flight.saturating_add(1);
301 }
302 }
303
304 pub fn record_cancel(&self, name: &str) {
306 if let Ok(mut guard) = self.inner.write()
307 && let Some(status) = guard.get_mut(name)
308 {
309 status.queued = status.queued.saturating_sub(1);
310 }
311 }
312
313 pub fn record_success(&self, name: &str) {
315 if let Ok(mut guard) = self.inner.write()
316 && let Some(status) = guard.get_mut(name)
317 {
318 status.in_flight = status.in_flight.saturating_sub(1);
319 status.total_successes = status.total_successes.saturating_add(1);
320 status.last_error = None;
321 }
322 }
323
324 pub fn record_retry(&self, name: &str, error: &str, _attempt: u32) {
326 if let Ok(mut guard) = self.inner.write()
327 && let Some(status) = guard.get_mut(name)
328 {
329 status.in_flight = status.in_flight.saturating_sub(1);
330 status.last_error = Some(error.to_string());
331 }
332 }
333
334 pub fn record_failure(&self, name: &str, error: String, dead_lettered: bool) {
336 if let Ok(mut guard) = self.inner.write()
337 && let Some(status) = guard.get_mut(name)
338 {
339 status.in_flight = status.in_flight.saturating_sub(1);
340 status.total_failures = status.total_failures.saturating_add(1);
341 status.last_error = Some(error);
342 if dead_lettered {
343 status.dead_letters = status.dead_letters.saturating_add(1);
344 }
345 }
346 }
347
348 #[must_use]
350 pub fn snapshot(&self) -> HashMap<String, JobStatus> {
351 self.inner.read().map(|g| g.clone()).unwrap_or_default()
352 }
353}
354
355impl Default for JobRegistry {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361impl TaskRegistry {
362 #[must_use]
364 pub fn new() -> Self {
365 Self {
366 inner: Arc::new(RwLock::new(HashMap::new())),
367 }
368 }
369
370 pub fn register(&self, name: &str, schedule: &str) {
372 self.register_scheduled(
373 name,
374 schedule,
375 crate::task::TaskCoordination::Fleet,
376 "in_process",
377 "unknown",
378 );
379 }
380
381 pub fn register_scheduled(
383 &self,
384 name: &str,
385 schedule: &str,
386 coordination: crate::task::TaskCoordination,
387 scheduler_backend: &str,
388 replica_id: &str,
389 ) {
390 let Ok(mut guard) = self.inner.write() else {
391 return;
392 };
393 guard.insert(
394 name.to_string(),
395 TaskStatus {
396 schedule: schedule.to_string(),
397 coordination,
398 scheduler_backend: scheduler_backend.to_string(),
399 replica_id: replica_id.to_string(),
400 current_leader: None,
401 last_tick: None,
402 last_fired_at: None,
403 next_run_at: None,
404 status: "idle".to_string(),
405 last_run: None,
406 last_duration_ms: None,
407 last_result: None,
408 last_error: None,
409 total_runs: 0,
410 total_failures: 0,
411 },
412 );
413 }
414
415 pub fn record_leader(&self, name: &str, leader_id: &str, tick_key: &str) {
417 let Ok(mut guard) = self.inner.write() else {
418 return;
419 };
420 let Some(task) = guard.get_mut(name) else {
421 return;
422 };
423 task.current_leader = Some(leader_id.to_string());
424 task.last_tick = Some(tick_key.to_string());
425 }
426
427 pub fn record_start(&self, name: &str) {
429 let Ok(mut guard) = self.inner.write() else {
430 return;
431 };
432 let Some(task) = guard.get_mut(name) else {
433 return;
434 };
435 task.status = "running".to_string();
436 task.next_run_at = None;
437 }
438
439 pub fn record_next_run_at(&self, name: &str, next_run_at: &str) {
441 let Ok(mut guard) = self.inner.write() else {
442 return;
443 };
444 let Some(task) = guard.get_mut(name) else {
445 return;
446 };
447 task.next_run_at = Some(next_run_at.to_string());
448 }
449
450 pub fn record_success(&self, name: &str, duration_ms: u64) {
452 let Ok(mut guard) = self.inner.write() else {
453 return;
454 };
455 let Some(task) = guard.get_mut(name) else {
456 return;
457 };
458 task.status = "idle".to_string();
459 let now = chrono::Utc::now().to_rfc3339();
460 task.last_run = Some(now.clone());
461 task.last_fired_at = Some(now);
462 task.last_duration_ms = Some(duration_ms);
463 task.last_result = Some("ok".to_string());
464 task.last_error = None;
465 task.total_runs += 1;
466 }
467
468 pub fn record_failure(&self, name: &str, duration_ms: u64, error: &str) {
470 let Ok(mut guard) = self.inner.write() else {
471 return;
472 };
473 let Some(task) = guard.get_mut(name) else {
474 return;
475 };
476 task.status = "idle".to_string();
477 let now = chrono::Utc::now().to_rfc3339();
478 task.last_run = Some(now.clone());
479 task.last_fired_at = Some(now);
480 task.last_duration_ms = Some(duration_ms);
481 task.last_result = Some("failed".to_string());
482 task.last_error = Some(error.to_string());
483 task.total_runs += 1;
484 task.total_failures += 1;
485 }
486
487 #[must_use]
489 pub fn snapshot(&self) -> HashMap<String, TaskStatus> {
490 self.inner
491 .read()
492 .map(|guard| guard.clone())
493 .unwrap_or_default()
494 }
495}
496
497impl Default for TaskRegistry {
498 fn default() -> Self {
499 Self::new()
500 }
501}
502
503impl std::fmt::Debug for TaskRegistry {
504 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505 f.debug_struct("TaskRegistry")
506 .field("count", &self.snapshot().len())
507 .finish()
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct ConfigProperty {
514 pub value: serde_json::Value,
516 pub source: String,
518}
519
520#[derive(Debug, Clone, Default)]
522pub struct ConfigProperties {
523 inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
524}
525
526impl ConfigProperties {
527 #[must_use]
529 #[allow(clippy::too_many_lines)]
530 pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
531 let profile = config.profile.as_deref().unwrap_or("default");
532 let defaults = crate::config::AutumnConfig::default();
533
534 let mut props = HashMap::with_capacity(32);
536 let profile_str = profile.to_string();
537
538 Self::track_server_props(&mut props, config, &defaults, &profile_str);
539 Self::track_db_props(&mut props, config, &defaults, &profile_str);
540 Self::track_log_props(&mut props, config, &defaults, &profile_str);
541 Self::track_telemetry_props(&mut props, config, &defaults, &profile_str);
542 Self::track_health_props(&mut props, config, &defaults, &profile_str);
543 Self::track_actuator_props(&mut props, config, &defaults, &profile_str);
544 Self::track_session_props(&mut props, config, &defaults, &profile_str);
545 Self::track_channels_props(&mut props, config, &defaults, &profile_str);
546
547 Self {
548 inner: Arc::new(RwLock::new(props)),
549 }
550 }
551
552 fn track_server_props(
553 props: &mut HashMap<String, ConfigProperty>,
554 config: &crate::config::AutumnConfig,
555 defaults: &crate::config::AutumnConfig,
556 profile_str: &str,
557 ) {
558 Self::track_property(
559 props,
560 "server.host",
561 &config.server.host,
562 &defaults.server.host,
563 profile_str,
564 );
565 Self::track_property(
566 props,
567 "server.port",
568 &config.server.port.to_string(),
569 &defaults.server.port.to_string(),
570 profile_str,
571 );
572 Self::track_property(
573 props,
574 "server.shutdown_timeout_secs",
575 &config.server.shutdown_timeout_secs.to_string(),
576 &defaults.server.shutdown_timeout_secs.to_string(),
577 profile_str,
578 );
579 }
580
581 fn track_db_props(
582 props: &mut HashMap<String, ConfigProperty>,
583 config: &crate::config::AutumnConfig,
584 defaults: &crate::config::AutumnConfig,
585 profile_str: &str,
586 ) {
587 let db_url = config.database.url.as_deref().unwrap_or("").to_string();
588 let primary_url = config
589 .database
590 .primary_url
591 .as_deref()
592 .unwrap_or("")
593 .to_string();
594 let replica_url = config
595 .database
596 .replica_url
597 .as_deref()
598 .unwrap_or("")
599 .to_string();
600 Self::track_property(props, "database.url", &db_url, "", profile_str);
601 Self::track_property(props, "database.primary_url", &primary_url, "", profile_str);
602 Self::track_property(props, "database.replica_url", &replica_url, "", profile_str);
603 Self::track_property(
604 props,
605 "database.pool_size",
606 &config.database.pool_size.to_string(),
607 &defaults.database.pool_size.to_string(),
608 profile_str,
609 );
610 Self::track_property(
611 props,
612 "database.primary_pool_size",
613 &config.database.effective_primary_pool_size().to_string(),
614 &defaults.database.effective_primary_pool_size().to_string(),
615 profile_str,
616 );
617 Self::track_property(
618 props,
619 "database.replica_pool_size",
620 &config.database.effective_replica_pool_size().to_string(),
621 &defaults.database.effective_replica_pool_size().to_string(),
622 profile_str,
623 );
624 Self::track_property(
625 props,
626 "database.replica_fallback",
627 &format!("{:?}", config.database.replica_fallback),
628 &format!("{:?}", defaults.database.replica_fallback),
629 profile_str,
630 );
631 }
632
633 fn track_log_props(
634 props: &mut HashMap<String, ConfigProperty>,
635 config: &crate::config::AutumnConfig,
636 defaults: &crate::config::AutumnConfig,
637 profile_str: &str,
638 ) {
639 Self::track_property(
640 props,
641 "log.level",
642 &config.log.level,
643 &defaults.log.level,
644 profile_str,
645 );
646 Self::track_property(
647 props,
648 "log.format",
649 &format!("{:?}", config.log.format),
650 &format!("{:?}", defaults.log.format),
651 profile_str,
652 );
653 }
654
655 fn track_telemetry_props(
656 props: &mut HashMap<String, ConfigProperty>,
657 config: &crate::config::AutumnConfig,
658 defaults: &crate::config::AutumnConfig,
659 profile_str: &str,
660 ) {
661 Self::track_property(
662 props,
663 "telemetry.enabled",
664 &config.telemetry.enabled.to_string(),
665 &defaults.telemetry.enabled.to_string(),
666 profile_str,
667 );
668 Self::track_property(
669 props,
670 "telemetry.service_name",
671 &config.telemetry.service_name,
672 &defaults.telemetry.service_name,
673 profile_str,
674 );
675 Self::track_property(
676 props,
677 "telemetry.service_namespace",
678 config.telemetry.service_namespace.as_deref().unwrap_or(""),
679 defaults
680 .telemetry
681 .service_namespace
682 .as_deref()
683 .unwrap_or(""),
684 profile_str,
685 );
686 Self::track_property(
687 props,
688 "telemetry.service_version",
689 &config.telemetry.service_version,
690 &defaults.telemetry.service_version,
691 profile_str,
692 );
693 Self::track_property(
694 props,
695 "telemetry.environment",
696 &config.telemetry.environment,
697 &defaults.telemetry.environment,
698 profile_str,
699 );
700 Self::track_property(
701 props,
702 "telemetry.otlp_endpoint",
703 config.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
704 defaults.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
705 profile_str,
706 );
707 Self::track_property(
708 props,
709 "telemetry.protocol",
710 &format!("{:?}", config.telemetry.protocol),
711 &format!("{:?}", defaults.telemetry.protocol),
712 profile_str,
713 );
714 Self::track_property(
715 props,
716 "telemetry.strict",
717 &config.telemetry.strict.to_string(),
718 &defaults.telemetry.strict.to_string(),
719 profile_str,
720 );
721 }
722
723 fn track_health_props(
724 props: &mut HashMap<String, ConfigProperty>,
725 config: &crate::config::AutumnConfig,
726 defaults: &crate::config::AutumnConfig,
727 profile_str: &str,
728 ) {
729 Self::track_property(
730 props,
731 "health.path",
732 &config.health.path,
733 &defaults.health.path,
734 profile_str,
735 );
736 Self::track_property(
737 props,
738 "health.live_path",
739 &config.health.live_path,
740 &defaults.health.live_path,
741 profile_str,
742 );
743 Self::track_property(
744 props,
745 "health.ready_path",
746 &config.health.ready_path,
747 &defaults.health.ready_path,
748 profile_str,
749 );
750 Self::track_property(
751 props,
752 "health.startup_path",
753 &config.health.startup_path,
754 &defaults.health.startup_path,
755 profile_str,
756 );
757 Self::track_property(
758 props,
759 "health.detailed",
760 &config.health.detailed.to_string(),
761 &defaults.health.detailed.to_string(),
762 profile_str,
763 );
764 }
765
766 fn track_actuator_props(
767 props: &mut HashMap<String, ConfigProperty>,
768 config: &crate::config::AutumnConfig,
769 defaults: &crate::config::AutumnConfig,
770 profile_str: &str,
771 ) {
772 Self::track_property(
773 props,
774 "actuator.prefix",
775 &config.actuator.prefix,
776 &defaults.actuator.prefix,
777 profile_str,
778 );
779 Self::track_property(
780 props,
781 "actuator.sensitive",
782 &config.actuator.sensitive.to_string(),
783 &defaults.actuator.sensitive.to_string(),
784 profile_str,
785 );
786 }
787
788 fn track_session_props(
789 props: &mut HashMap<String, ConfigProperty>,
790 config: &crate::config::AutumnConfig,
791 defaults: &crate::config::AutumnConfig,
792 profile_str: &str,
793 ) {
794 Self::track_property(
795 props,
796 "session.backend",
797 &format!("{:?}", config.session.backend),
798 &format!("{:?}", defaults.session.backend),
799 profile_str,
800 );
801 Self::track_property(
802 props,
803 "session.cookie_name",
804 &config.session.cookie_name,
805 &defaults.session.cookie_name,
806 profile_str,
807 );
808 Self::track_property(
809 props,
810 "session.max_age_secs",
811 &config.session.max_age_secs.to_string(),
812 &defaults.session.max_age_secs.to_string(),
813 profile_str,
814 );
815 Self::track_property(
816 props,
817 "session.secure",
818 &config.session.secure.to_string(),
819 &defaults.session.secure.to_string(),
820 profile_str,
821 );
822 Self::track_property(
823 props,
824 "session.same_site",
825 &config.session.same_site,
826 &defaults.session.same_site,
827 profile_str,
828 );
829 Self::track_property(
830 props,
831 "session.http_only",
832 &config.session.http_only.to_string(),
833 &defaults.session.http_only.to_string(),
834 profile_str,
835 );
836 Self::track_property(
837 props,
838 "session.path",
839 &config.session.path,
840 &defaults.session.path,
841 profile_str,
842 );
843 Self::track_property(
844 props,
845 "session.allow_memory_in_production",
846 &config.session.allow_memory_in_production.to_string(),
847 &defaults.session.allow_memory_in_production.to_string(),
848 profile_str,
849 );
850 Self::track_property(
851 props,
852 "session.redis.url",
853 config.session.redis.url.as_deref().unwrap_or(""),
854 defaults.session.redis.url.as_deref().unwrap_or(""),
855 profile_str,
856 );
857 Self::track_property(
858 props,
859 "session.redis.key_prefix",
860 &config.session.redis.key_prefix,
861 &defaults.session.redis.key_prefix,
862 profile_str,
863 );
864 }
865
866 fn track_channels_props(
867 props: &mut HashMap<String, ConfigProperty>,
868 config: &crate::config::AutumnConfig,
869 defaults: &crate::config::AutumnConfig,
870 profile_str: &str,
871 ) {
872 Self::track_property(
873 props,
874 "channels.backend",
875 &format!("{:?}", config.channels.backend),
876 &format!("{:?}", defaults.channels.backend),
877 profile_str,
878 );
879 Self::track_property(
880 props,
881 "channels.capacity",
882 &config.channels.capacity.to_string(),
883 &defaults.channels.capacity.to_string(),
884 profile_str,
885 );
886 Self::track_property(
887 props,
888 "channels.redis.url",
889 config.channels.redis.url.as_deref().unwrap_or(""),
890 defaults.channels.redis.url.as_deref().unwrap_or(""),
891 profile_str,
892 );
893 Self::track_property(
894 props,
895 "channels.redis.key_prefix",
896 &config.channels.redis.key_prefix,
897 &defaults.channels.redis.key_prefix,
898 profile_str,
899 );
900 }
901
902 fn track_property(
903 props: &mut HashMap<String, ConfigProperty>,
904 key: &str,
905 value: &str,
906 default_value: &str,
907 profile: &str,
908 ) {
909 let env_key = format!("AUTUMN_{}", key.replace('.', "__").to_uppercase());
911 let source = if std::env::var(&env_key).is_ok() {
912 env_key
913 } else if value != default_value && (profile == "dev" || profile == "prod") {
914 format!("profile_default:{profile}")
915 } else if value != default_value {
916 "autumn.toml".to_string()
917 } else {
918 "default".to_string()
919 };
920
921 let display_value = if should_redact(key) {
922 serde_json::Value::String("****".into())
923 } else {
924 serde_json::Value::String(value.to_string())
925 };
926
927 props.insert(
928 key.to_string(),
929 ConfigProperty {
930 value: display_value,
931 source,
932 },
933 );
934 }
935
936 #[must_use]
938 pub fn snapshot(&self) -> HashMap<String, ConfigProperty> {
939 self.inner
940 .read()
941 .map(|guard| guard.clone())
942 .unwrap_or_default()
943 }
944}
945
946#[derive(Serialize)]
950struct ActuatorHealth {
951 status: &'static str,
952 version: &'static str,
953 profile: String,
954 uptime: String,
955 #[serde(skip_serializing_if = "Option::is_none")]
956 checks: Option<HealthChecks>,
957}
958
959#[derive(Serialize)]
960struct HealthChecks {
961 #[serde(skip_serializing_if = "Option::is_none")]
962 database: Option<DatabaseCheck>,
963}
964
965#[derive(Serialize)]
966struct DatabaseCheck {
967 status: &'static str,
968 pool_size: u64,
969 active_connections: u64,
970 idle_connections: u64,
971}
972
973pub async fn health<S: ProvideActuatorState + Send + Sync + 'static>(
975 State(state): State<S>,
976) -> impl IntoResponse {
977 let (overall_healthy, db_check) = {
978 #[cfg(feature = "db")]
979 {
980 #[allow(clippy::option_if_let_else)]
981 if let Some(pool) = state.pool() {
982 let status = pool.status();
983 let available = status.available as u64;
984 let size = status.max_size as u64;
985 let waiting = status.waiting as u64;
986 let idle = available;
987 let active = size.saturating_sub(available);
988
989 let overall_healthy = available > 0 || waiting == 0;
990 let db_check = Some(DatabaseCheck {
991 status: if overall_healthy { "ok" } else { "down" },
992 pool_size: size,
993 active_connections: active,
994 idle_connections: idle,
995 });
996 (overall_healthy, db_check)
997 } else {
998 (true, None)
999 }
1000 }
1001
1002 #[cfg(not(feature = "db"))]
1003 {
1004 (true, None)
1005 }
1006 };
1007
1008 let checks = db_check.map(|db| HealthChecks { database: Some(db) });
1009
1010 let body = ActuatorHealth {
1011 status: if overall_healthy { "ok" } else { "degraded" },
1012 version: env!("CARGO_PKG_VERSION"),
1013 profile: state.profile().to_owned(),
1014 uptime: state.uptime_display(),
1015 checks,
1016 };
1017
1018 let code = if overall_healthy {
1019 StatusCode::OK
1020 } else {
1021 StatusCode::SERVICE_UNAVAILABLE
1022 };
1023 (code, Json(body))
1024}
1025
1026#[derive(Serialize)]
1030pub(crate) struct ActuatorInfo {
1031 app: AppInfo,
1032 autumn: FrameworkInfo,
1033 runtime: RuntimeInfo,
1034}
1035
1036#[derive(Serialize)]
1037struct AppInfo {
1038 name: String,
1039 version: String,
1040}
1041
1042#[derive(Serialize)]
1043struct FrameworkInfo {
1044 version: &'static str,
1045 profile: String,
1046}
1047
1048#[derive(Serialize)]
1049struct RuntimeInfo {
1050 uptime: String,
1051}
1052
1053pub(crate) async fn info<S: ProvideActuatorState + Send + Sync + 'static>(
1055 State(state): State<S>,
1056) -> Json<ActuatorInfo> {
1057 Json(ActuatorInfo {
1058 app: AppInfo {
1059 name: std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".into()),
1060 version: std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()),
1061 },
1062 autumn: FrameworkInfo {
1063 version: env!("CARGO_PKG_VERSION"),
1064 profile: state.profile().to_owned(),
1065 },
1066 runtime: RuntimeInfo {
1067 uptime: state.uptime_display(),
1068 },
1069 })
1070}
1071
1072#[derive(Serialize)]
1076pub(crate) struct ActuatorEnv {
1077 active_profile: String,
1078 properties: std::collections::HashMap<String, serde_json::Value>,
1079}
1080
1081const REDACT_PATTERNS: &[&str] = &[
1083 "password",
1084 "secret",
1085 "key",
1086 "token",
1087 "credential",
1088 "auth",
1089 "url",
1090];
1091
1092fn should_redact(key: &str) -> bool {
1093 let lower = key.to_lowercase();
1094 REDACT_PATTERNS.iter().any(|p| lower.contains(p))
1095}
1096
1097pub(crate) async fn env_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1099 State(state): State<S>,
1100) -> Json<ActuatorEnv> {
1101 let properties = state
1102 .config_props()
1103 .snapshot()
1104 .into_iter()
1105 .map(|(key, prop)| (key, prop.value))
1106 .collect();
1107
1108 Json(ActuatorEnv {
1109 active_profile: state.profile().to_owned(),
1110 properties,
1111 })
1112}
1113
1114pub(crate) async fn metrics_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1118 State(state): State<S>,
1119) -> Json<serde_json::Value> {
1120 let snapshot = state.metrics().snapshot();
1121 let result = serde_json::to_value(&snapshot).unwrap_or_default();
1122
1123 #[cfg(feature = "db")]
1125 let result = {
1126 let mut result = result;
1127 if let Some(pool) = state.pool() {
1128 let status = pool.status();
1129 let db_stats = serde_json::json!({
1130 "pool_size": status.max_size,
1131 "active_connections": (status.max_size as u64).saturating_sub(status.available as u64),
1132 "idle_connections": status.available,
1133 });
1134 if let serde_json::Value::Object(ref mut map) = result {
1135 map.insert("database".to_string(), db_stats);
1136 }
1137 }
1138 result
1139 };
1140
1141 Json(result)
1142}
1143
1144pub(crate) async fn prometheus_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1148 State(state): State<S>,
1149) -> impl IntoResponse {
1150 use std::fmt::Write;
1151
1152 let snapshot = state.metrics().snapshot();
1153 let mut out = String::with_capacity(1024);
1154
1155 out.push_str("# HELP autumn_http_requests_total Total number of HTTP requests\n");
1157 out.push_str("# TYPE autumn_http_requests_total counter\n");
1158 let _ = writeln!(
1159 out,
1160 "autumn_http_requests_total {}",
1161 snapshot.http.requests_total
1162 );
1163
1164 out.push_str("# HELP autumn_http_requests_active Currently active HTTP requests\n");
1166 out.push_str("# TYPE autumn_http_requests_active gauge\n");
1167 let _ = writeln!(
1168 out,
1169 "autumn_http_requests_active {}",
1170 snapshot.http.requests_active
1171 );
1172
1173 out.push_str("# HELP autumn_http_responses_total HTTP responses by status code\n");
1175 out.push_str("# TYPE autumn_http_responses_total counter\n");
1176 let _ = writeln!(
1177 out,
1178 "autumn_http_responses_total{{status=\"2xx\"}} {}",
1179 snapshot.http.by_status.s2xx
1180 );
1181 let _ = writeln!(
1182 out,
1183 "autumn_http_responses_total{{status=\"3xx\"}} {}",
1184 snapshot.http.by_status.s3xx
1185 );
1186 let _ = writeln!(
1187 out,
1188 "autumn_http_responses_total{{status=\"4xx\"}} {}",
1189 snapshot.http.by_status.s4xx
1190 );
1191 let _ = writeln!(
1192 out,
1193 "autumn_http_responses_total{{status=\"5xx\"}} {}",
1194 snapshot.http.by_status.s5xx
1195 );
1196
1197 if !snapshot.http.by_route.is_empty() {
1199 out.push_str("# HELP autumn_http_route_requests_total HTTP requests by route and method\n");
1200 out.push_str("# TYPE autumn_http_route_requests_total counter\n");
1201 for (route_key, metrics) in &snapshot.http.by_route {
1202 if let Some((method, path)) = route_key.split_once(' ') {
1204 let _ = writeln!(
1205 out,
1206 "autumn_http_route_requests_total{{method=\"{}\",route=\"{}\"}} {}",
1207 method, path, metrics.count
1208 );
1209 }
1210 }
1211 }
1212
1213 (
1214 [(
1215 axum::http::header::CONTENT_TYPE,
1216 "text/plain; version=0.0.4",
1217 )],
1218 out,
1219 )
1220}
1221
1222pub(crate) async fn configprops_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1226 State(state): State<S>,
1227) -> Json<serde_json::Value> {
1228 let props = state.config_props().snapshot();
1229
1230 Json(serde_json::json!({
1231 "active_profile": state.profile(),
1232 "properties": props,
1233 }))
1234}
1235
1236const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
1240
1241#[derive(Serialize)]
1243pub(crate) struct LoggersResponse {
1244 current_level: String,
1245 available_levels: Vec<&'static str>,
1246 loggers: HashMap<String, String>,
1247}
1248
1249pub(crate) async fn loggers_get<S: ProvideActuatorState + Send + Sync + 'static>(
1251 State(state): State<S>,
1252) -> Json<LoggersResponse> {
1253 Json(LoggersResponse {
1254 current_level: state.log_levels().current_level(),
1255 available_levels: AVAILABLE_LEVELS.to_vec(),
1256 loggers: state.log_levels().logger_overrides(),
1257 })
1258}
1259
1260#[derive(Deserialize)]
1262pub(crate) struct SetLoggerRequest {
1263 level: String,
1264}
1265
1266pub(crate) async fn loggers_put<S: ProvideActuatorState + Send + Sync + 'static>(
1268 State(state): State<S>,
1269 Path(name): Path<String>,
1270 Json(body): Json<SetLoggerRequest>,
1271) -> impl IntoResponse {
1272 let level = body.level.to_lowercase();
1273
1274 if !AVAILABLE_LEVELS.contains(&level.as_str()) {
1276 return (
1277 StatusCode::BAD_REQUEST,
1278 Json(serde_json::json!({
1279 "status": "error",
1280 "message": format!(
1281 "Invalid level '{}'. Available levels: {}",
1282 level,
1283 AVAILABLE_LEVELS.join(", ")
1284 ),
1285 })),
1286 );
1287 }
1288
1289 let previous = state.log_levels().set_logger_level(&name, &level);
1290
1291 (
1292 StatusCode::OK,
1293 Json(serde_json::json!({
1294 "status": "ok",
1295 "message": format!("Logger '{}' set to '{}'", name, level),
1296 "previous": previous,
1297 })),
1298 )
1299}
1300
1301pub(crate) async fn tasks_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1305 State(state): State<S>,
1306) -> Json<serde_json::Value> {
1307 let tasks = state.task_registry().snapshot();
1308
1309 Json(serde_json::json!({
1310 "scheduled_tasks": tasks,
1311 }))
1312}
1313
1314pub(crate) async fn jobs_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1316 State(state): State<S>,
1317) -> Json<serde_json::Value> {
1318 let jobs = state.job_registry().snapshot();
1319 Json(serde_json::json!({ "jobs": jobs }))
1320}
1321
1322pub(crate) async fn a11y_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1329 State(state): State<S>,
1330) -> Json<A11yPosture> {
1331 Json(state.a11y_posture())
1332}
1333
1334#[cfg(feature = "ws")]
1338pub(crate) async fn channels_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1339 State(state): State<S>,
1340) -> Json<serde_json::Value> {
1341 let channels = state.channels().snapshot();
1342 Json(serde_json::json!({
1343 "channels": channels,
1344 }))
1345}
1346
1347#[cfg(feature = "ws")]
1351pub(crate) async fn tasks_stream_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
1352 State(state): State<S>,
1353 ws: axum::extract::ws::WebSocketUpgrade,
1354) -> impl IntoResponse {
1355 ws.on_upgrade(move |mut socket| async move {
1356 let mut rx = state.channels().subscribe("sys:tasks");
1357 let shutdown = state.shutdown_token();
1358
1359 loop {
1360 tokio::select! {
1361 res = rx.recv() => {
1362 match res {
1363 Ok(msg) => {
1364 let ws_msg = axum::extract::ws::Message::Text(msg.into_string().into());
1365 if socket.send(ws_msg).await.is_err() {
1366 break;
1367 }
1368 }
1369 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1370 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1371 }
1372 }
1373 () = shutdown.cancelled() => {
1374 let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
1375 break;
1376 }
1377 else => break,
1378 }
1379 }
1380 })
1381}
1382
1383pub(crate) fn normalize_actuator_prefix(prefix: &str) -> String {
1386 let trimmed = prefix.trim();
1387 if trimmed.is_empty() || trimmed == "/" {
1388 String::new()
1389 } else {
1390 let trimmed = trimmed.trim_end_matches('/');
1391 if trimmed.starts_with('/') {
1392 trimmed.to_owned()
1393 } else {
1394 format!("/{trimmed}")
1395 }
1396 }
1397}
1398
1399pub(crate) fn actuator_route_glob(prefix: &str) -> String {
1400 let prefix = normalize_actuator_prefix(prefix);
1401 if prefix.is_empty() {
1402 "/*".to_owned()
1403 } else {
1404 format!("{prefix}/*")
1405 }
1406}
1407
1408pub(crate) fn actuator_route_path(prefix: &str, suffix: &str) -> String {
1409 let prefix = normalize_actuator_prefix(prefix);
1410 if prefix.is_empty() {
1411 suffix.to_owned()
1412 } else {
1413 format!("{prefix}{suffix}")
1414 }
1415}
1416
1417pub(crate) fn actuator_endpoint_paths(prefix: &str, sensitive: bool) -> Vec<String> {
1418 let mut paths = vec![
1419 actuator_route_path(prefix, "/health"),
1420 actuator_route_path(prefix, "/info"),
1421 actuator_route_path(prefix, "/metrics"),
1422 actuator_route_path(prefix, "/a11y"),
1423 actuator_route_path(prefix, "/ui"),
1424 actuator_route_path(prefix, "/ui/metrics"),
1425 ];
1426
1427 if sensitive {
1428 paths.push(actuator_route_path(prefix, "/env"));
1429 paths.push(actuator_route_path(prefix, "/configprops"));
1430 paths.push(actuator_route_path(prefix, "/loggers"));
1431 paths.push(actuator_route_path(prefix, "/tasks"));
1432 paths.push(actuator_route_path(prefix, "/jobs"));
1433 paths.push(actuator_route_path(prefix, "/ui/tasks"));
1434 paths.push(actuator_route_path(prefix, "/prometheus"));
1435 #[cfg(feature = "ws")]
1436 {
1437 paths.push(actuator_route_path(prefix, "/channels"));
1438 paths.push(actuator_route_path(prefix, "/tasks/stream"));
1439 }
1440 }
1441
1442 paths
1443}
1444
1445pub fn actuator_router<S: ProvideActuatorState + Send + Sync + Clone + 'static>(
1450 sensitive: bool,
1451) -> axum::Router<S> {
1452 actuator_router_with_prefix("/actuator", sensitive)
1453}
1454
1455pub(crate) fn actuator_router_with_prefix<
1459 S: ProvideActuatorState + Send + Sync + Clone + 'static,
1460>(
1461 prefix: &str,
1462 sensitive: bool,
1463) -> axum::Router<S> {
1464 let mut router = axum::Router::new()
1465 .route(
1466 &actuator_route_path(prefix, "/health"),
1467 axum::routing::get(health::<S>),
1468 )
1469 .route(
1470 &actuator_route_path(prefix, "/info"),
1471 axum::routing::get(info::<S>),
1472 )
1473 .route(
1474 &actuator_route_path(prefix, "/metrics"),
1475 axum::routing::get(metrics_endpoint::<S>),
1476 )
1477 .route(
1478 &actuator_route_path(prefix, "/a11y"),
1479 axum::routing::get(a11y_endpoint::<S>),
1480 );
1481
1482 if sensitive {
1483 router = router
1484 .route(
1485 &actuator_route_path(prefix, "/env"),
1486 axum::routing::get(env_endpoint::<S>),
1487 )
1488 .route(
1489 &actuator_route_path(prefix, "/prometheus"),
1490 axum::routing::get(prometheus_endpoint::<S>),
1491 )
1492 .route(
1493 &actuator_route_path(prefix, "/configprops"),
1494 axum::routing::get(configprops_endpoint::<S>),
1495 )
1496 .route(
1497 &actuator_route_path(prefix, "/loggers"),
1498 axum::routing::get(loggers_get::<S>),
1499 )
1500 .route(
1501 &actuator_route_path(prefix, "/loggers/{name}"),
1502 axum::routing::put(loggers_put::<S>),
1503 )
1504 .route(
1505 &actuator_route_path(prefix, "/tasks"),
1506 axum::routing::get(tasks_endpoint::<S>),
1507 )
1508 .route(
1509 &actuator_route_path(prefix, "/jobs"),
1510 axum::routing::get(jobs_endpoint::<S>),
1511 )
1512 .route(
1513 &actuator_route_path(prefix, "/ui/tasks"),
1514 axum::routing::get(ui_tasks::<S>),
1515 );
1516
1517 #[cfg(feature = "system-info")]
1518 {
1519 router = router.route(
1520 &actuator_route_path(prefix, "/system"),
1521 axum::routing::get(crate::system_info::system_info_handler),
1522 );
1523 }
1524
1525 #[cfg(feature = "ws")]
1526 {
1527 router = router
1528 .route(
1529 &actuator_route_path(prefix, "/channels"),
1530 axum::routing::get(channels_endpoint::<S>),
1531 )
1532 .route(
1533 &actuator_route_path(prefix, "/tasks/stream"),
1534 axum::routing::get(tasks_stream_endpoint::<S>),
1535 );
1536 }
1537 }
1538
1539 router
1541 .route(
1542 &actuator_route_path(prefix, "/ui"),
1543 axum::routing::get(ui_dashboard),
1544 )
1545 .route(
1546 &actuator_route_path(prefix, "/ui/metrics"),
1547 axum::routing::get(ui_metrics::<S>),
1548 )
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553 use super::*;
1554 use crate::config::AutumnConfig;
1555
1556 #[test]
1557 fn task_registry_flow() {
1558 let registry = TaskRegistry::new();
1559
1560 registry.register_scheduled(
1561 "my_task",
1562 "0 * * * * *",
1563 crate::task::TaskCoordination::Fleet,
1564 "mock",
1565 "node-1",
1566 );
1567 let snap1 = registry.snapshot();
1568 assert_eq!(snap1.get("my_task").unwrap().total_runs, 0);
1569
1570 registry.record_leader("my_task", "node-1", "mock_tick");
1571 let snap3 = registry.snapshot();
1572 assert_eq!(
1573 snap3.get("my_task").unwrap().current_leader.as_deref(),
1574 Some("node-1")
1575 );
1576
1577 registry.record_start("my_task");
1578 let snap4 = registry.snapshot();
1579 assert_eq!(snap4.get("my_task").unwrap().status, "running");
1580
1581 registry.record_next_run_at("my_task", "tomorrow");
1582 let snap5 = registry.snapshot();
1583 assert_eq!(
1584 snap5.get("my_task").unwrap().next_run_at.as_deref(),
1585 Some("tomorrow")
1586 );
1587
1588 registry.record_success("my_task", 100);
1589 let snap6 = registry.snapshot();
1590 assert_eq!(snap6.get("my_task").unwrap().total_runs, 1);
1591 assert_eq!(snap6.get("my_task").unwrap().last_error, None);
1592
1593 registry.record_failure("my_task", 150, "error message");
1594 let snap7 = registry.snapshot();
1595 assert_eq!(snap7.get("my_task").unwrap().total_runs, 2);
1596 assert_eq!(snap7.get("my_task").unwrap().total_failures, 1);
1597 assert_eq!(
1598 snap7.get("my_task").unwrap().last_error.as_deref(),
1599 Some("error message")
1600 );
1601
1602 let registry2 = TaskRegistry::default();
1603 assert!(registry2.snapshot().is_empty());
1604 }
1605 #[test]
1606 fn job_registry_flow() {
1607 let registry = JobRegistry::new();
1608
1609 registry.register("my_job");
1610 let snap1 = registry.snapshot();
1611 assert_eq!(snap1.get("my_job").unwrap().queued, 0);
1612
1613 registry.record_enqueue("my_job");
1614 let snap2 = registry.snapshot();
1615 assert_eq!(snap2.get("my_job").unwrap().queued, 1);
1616
1617 registry.record_start("my_job");
1618 let snap3 = registry.snapshot();
1619 assert_eq!(snap3.get("my_job").unwrap().queued, 0);
1620 assert_eq!(snap3.get("my_job").unwrap().in_flight, 1);
1621
1622 registry.record_retry("my_job", "timeout", 1);
1623 let snap4 = registry.snapshot();
1624 assert_eq!(snap4.get("my_job").unwrap().in_flight, 0);
1625 assert_eq!(
1626 snap4.get("my_job").unwrap().last_error.as_deref(),
1627 Some("timeout")
1628 );
1629
1630 registry.record_enqueue("my_job");
1631 registry.record_start("my_job");
1632 registry.record_success("my_job");
1633 let snap5 = registry.snapshot();
1634 assert_eq!(snap5.get("my_job").unwrap().in_flight, 0);
1635 assert_eq!(snap5.get("my_job").unwrap().total_successes, 1);
1636 assert_eq!(snap5.get("my_job").unwrap().last_error, None);
1637
1638 registry.record_enqueue("my_job");
1639 registry.record_cancel("my_job");
1640 let snap6 = registry.snapshot();
1641 assert_eq!(snap6.get("my_job").unwrap().queued, 0);
1642 assert_eq!(snap6.get("my_job").unwrap().in_flight, 0);
1643
1644 registry.record_enqueue("my_job");
1645 registry.record_start("my_job");
1646 registry.record_failure("my_job", "failure".to_string(), true);
1647 let snap7 = registry.snapshot();
1648 assert_eq!(snap7.get("my_job").unwrap().in_flight, 0);
1649 assert_eq!(snap7.get("my_job").unwrap().total_failures, 1);
1650 assert_eq!(snap7.get("my_job").unwrap().dead_letters, 1);
1651 assert_eq!(
1652 snap7.get("my_job").unwrap().last_error.as_deref(),
1653 Some("failure")
1654 );
1655
1656 let registry2 = JobRegistry::default();
1657 let snap8 = registry2.snapshot();
1658 assert!(snap8.is_empty());
1659 }
1660 use axum::body::Body;
1661 use axum::http::Request;
1662 use tower::ServiceExt;
1663
1664 #[derive(Clone)]
1665 struct TestActuatorState {
1666 profile: String,
1667 metrics: crate::middleware::MetricsCollector,
1668 log_levels: LogLevels,
1669 task_registry: TaskRegistry,
1670 job_registry: JobRegistry,
1671 config_props: ConfigProperties,
1672 #[cfg(feature = "db")]
1673 pool: Option<
1674 diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
1675 >,
1676 #[cfg(feature = "ws")]
1677 channels: crate::channels::Channels,
1678 #[cfg(feature = "ws")]
1679 shutdown: tokio_util::sync::CancellationToken,
1680 }
1681
1682 impl ProvideActuatorState for TestActuatorState {
1683 fn metrics(&self) -> &crate::middleware::MetricsCollector {
1684 &self.metrics
1685 }
1686 fn log_levels(&self) -> &LogLevels {
1687 &self.log_levels
1688 }
1689 fn task_registry(&self) -> &TaskRegistry {
1690 &self.task_registry
1691 }
1692 fn job_registry(&self) -> &JobRegistry {
1693 &self.job_registry
1694 }
1695 fn config_props(&self) -> &ConfigProperties {
1696 &self.config_props
1697 }
1698 fn profile(&self) -> &str {
1699 &self.profile
1700 }
1701 fn uptime_display(&self) -> String {
1702 "test_uptime".to_string()
1703 }
1704 #[cfg(feature = "db")]
1705 fn pool(
1706 &self,
1707 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>>
1708 {
1709 self.pool.as_ref()
1710 }
1711 #[cfg(feature = "ws")]
1712 fn channels(&self) -> &crate::channels::Channels {
1713 &self.channels
1714 }
1715 #[cfg(feature = "ws")]
1716 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken {
1717 self.shutdown.clone()
1718 }
1719 }
1720
1721 fn test_state() -> TestActuatorState {
1722 test_state_with_config(&AutumnConfig::default())
1723 }
1724
1725 fn test_state_with_config(config: &AutumnConfig) -> TestActuatorState {
1726 TestActuatorState {
1727 profile: config.profile.clone().unwrap_or_else(|| "dev".into()),
1728 metrics: crate::middleware::MetricsCollector::new(),
1729 log_levels: LogLevels::new("info"),
1730 task_registry: TaskRegistry::new(),
1731 job_registry: JobRegistry::new(),
1732 config_props: ConfigProperties::from_config(config),
1733 #[cfg(feature = "db")]
1734 pool: None,
1735 #[cfg(feature = "ws")]
1736 channels: crate::channels::Channels::new(32),
1737 #[cfg(feature = "ws")]
1738 shutdown: tokio_util::sync::CancellationToken::new(),
1739 }
1740 }
1741
1742 #[tokio::test]
1743 async fn actuator_health_returns_ok() {
1744 let app = actuator_router(true).with_state(test_state());
1745 let resp = app
1746 .oneshot(
1747 Request::builder()
1748 .uri("/actuator/health")
1749 .body(Body::empty())
1750 .unwrap(),
1751 )
1752 .await
1753 .unwrap();
1754
1755 assert_eq!(resp.status(), StatusCode::OK);
1756 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1757 .await
1758 .unwrap();
1759 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1760 assert_eq!(json["status"], "ok");
1761 assert_eq!(json["profile"], "dev");
1762 assert!(json["uptime"].is_string());
1763 }
1764
1765 #[tokio::test]
1766 async fn actuator_routes_respect_custom_prefix() {
1767 let app = actuator_router_with_prefix("/ops", true).with_state(test_state());
1768
1769 let prefixed = app
1770 .clone()
1771 .oneshot(
1772 Request::builder()
1773 .uri("/ops/health")
1774 .body(Body::empty())
1775 .unwrap(),
1776 )
1777 .await
1778 .unwrap();
1779 assert_eq!(prefixed.status(), StatusCode::OK);
1780
1781 let legacy = app
1782 .oneshot(
1783 Request::builder()
1784 .uri("/actuator/health")
1785 .body(Body::empty())
1786 .unwrap(),
1787 )
1788 .await
1789 .unwrap();
1790 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
1791 }
1792
1793 #[test]
1794 fn actuator_route_helpers_normalize_prefixes() {
1795 assert_eq!(actuator_route_glob("ops/"), "/ops/*");
1796 assert_eq!(actuator_route_path("ops/", "/health"), "/ops/health");
1797 assert_eq!(actuator_route_glob("/"), "/*");
1798 }
1799
1800 #[tokio::test]
1801 async fn actuator_info_returns_metadata() {
1802 let app = actuator_router(true).with_state(test_state());
1803 let resp = app
1804 .oneshot(
1805 Request::builder()
1806 .uri("/actuator/info")
1807 .body(Body::empty())
1808 .unwrap(),
1809 )
1810 .await
1811 .unwrap();
1812
1813 assert_eq!(resp.status(), StatusCode::OK);
1814 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1815 .await
1816 .unwrap();
1817 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1818 assert!(json["autumn"]["version"].is_string());
1819 assert_eq!(json["autumn"]["profile"], "dev");
1820 }
1821
1822 #[tokio::test]
1823 async fn actuator_env_available_in_sensitive_mode() {
1824 let config = AutumnConfig {
1825 profile: Some("prod".into()),
1826 server: crate::config::ServerConfig {
1827 port: 4100,
1828 ..crate::config::ServerConfig::default()
1829 },
1830 telemetry: crate::config::TelemetryConfig {
1831 enabled: true,
1832 service_name: "cloud-app".into(),
1833 ..crate::config::TelemetryConfig::default()
1834 },
1835 health: crate::config::HealthConfig {
1836 path: "/healthz".into(),
1837 ..crate::config::HealthConfig::default()
1838 },
1839 ..AutumnConfig::default()
1840 };
1841
1842 let app = actuator_router(true).with_state(test_state_with_config(&config));
1843 let resp = app
1844 .oneshot(
1845 Request::builder()
1846 .uri("/actuator/env")
1847 .body(Body::empty())
1848 .unwrap(),
1849 )
1850 .await
1851 .unwrap();
1852 assert_eq!(resp.status(), StatusCode::OK);
1853 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1854 .await
1855 .unwrap();
1856 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1857 assert_eq!(json["active_profile"], "prod");
1858 assert_eq!(json["properties"]["server.port"], "4100");
1859 assert_eq!(json["properties"]["telemetry.enabled"], "true");
1860 assert_eq!(json["properties"]["telemetry.service_name"], "cloud-app");
1861 assert_eq!(json["properties"]["health.path"], "/healthz");
1862 }
1863
1864 #[tokio::test]
1865 async fn actuator_env_hidden_in_nonsensitive_mode() {
1866 let app = actuator_router(false).with_state(test_state());
1867 let resp = app
1868 .oneshot(
1869 Request::builder()
1870 .uri("/actuator/env")
1871 .body(Body::empty())
1872 .unwrap(),
1873 )
1874 .await
1875 .unwrap();
1876 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1877 }
1878
1879 #[test]
1880 fn redaction_patterns() {
1881 assert!(should_redact("database.url"));
1882 assert!(should_redact("api_token"));
1883 assert!(should_redact("secret_key"));
1884 assert!(!should_redact("server.port"));
1885 assert!(!should_redact("log.level"));
1886 }
1887
1888 #[tokio::test]
1891 async fn actuator_metrics_returns_http_stats() {
1892 let state = test_state();
1893 state.metrics().record("GET", "/test", 200, 10);
1894 state.metrics().record("POST", "/test", 500, 50);
1895
1896 let app = actuator_router(true).with_state(state);
1897 let resp = app
1898 .oneshot(
1899 Request::builder()
1900 .uri("/actuator/metrics")
1901 .body(Body::empty())
1902 .unwrap(),
1903 )
1904 .await
1905 .unwrap();
1906
1907 assert_eq!(resp.status(), StatusCode::OK);
1908 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1909 .await
1910 .unwrap();
1911 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1912 assert_eq!(json["http"]["requests_total"], 2);
1913 assert_eq!(json["http"]["by_status"]["2xx"], 1);
1914 assert_eq!(json["http"]["by_status"]["5xx"], 1);
1915 }
1916
1917 #[tokio::test]
1918 async fn actuator_metrics_available_in_nonsensitive_mode() {
1919 let app = actuator_router(false).with_state(test_state());
1920 let resp = app
1921 .oneshot(
1922 Request::builder()
1923 .uri("/actuator/metrics")
1924 .body(Body::empty())
1925 .unwrap(),
1926 )
1927 .await
1928 .unwrap();
1929 assert_eq!(resp.status(), StatusCode::OK);
1930 }
1931
1932 #[tokio::test]
1933 #[cfg(feature = "db")]
1934 async fn actuator_metrics_returns_db_stats_when_pool_present() {
1935 use diesel_async::AsyncPgConnection;
1936 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1937 use diesel_async::pooled_connection::deadpool::Pool;
1938
1939 let mut state = test_state();
1940
1941 let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(
1942 "postgres://postgres:postgres@localhost:5432/postgres",
1943 );
1944 let pool = Pool::builder(manager).build().unwrap();
1945
1946 state.pool = Some(pool);
1947
1948 let app = actuator_router(true).with_state(state);
1949 let resp = app
1950 .oneshot(
1951 Request::builder()
1952 .uri("/actuator/metrics")
1953 .body(Body::empty())
1954 .unwrap(),
1955 )
1956 .await
1957 .unwrap();
1958
1959 assert_eq!(resp.status(), StatusCode::OK);
1960 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1961 .await
1962 .unwrap();
1963 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1964
1965 assert!(json.get("database").is_some());
1966 }
1967
1968 #[tokio::test]
1971 async fn actuator_configprops_returns_properties() {
1972 let app = actuator_router(true).with_state(test_state());
1973 let resp = app
1974 .oneshot(
1975 Request::builder()
1976 .uri("/actuator/configprops")
1977 .body(Body::empty())
1978 .unwrap(),
1979 )
1980 .await
1981 .unwrap();
1982
1983 assert_eq!(resp.status(), StatusCode::OK);
1984 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1985 .await
1986 .unwrap();
1987 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1988 assert_eq!(json["active_profile"], "dev");
1989 assert!(json["properties"].is_object());
1990 }
1991
1992 #[tokio::test]
1993 async fn actuator_configprops_hidden_in_nonsensitive_mode() {
1994 let app = actuator_router(false).with_state(test_state());
1995 let resp = app
1996 .oneshot(
1997 Request::builder()
1998 .uri("/actuator/configprops")
1999 .body(Body::empty())
2000 .unwrap(),
2001 )
2002 .await
2003 .unwrap();
2004 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2005 }
2006
2007 #[test]
2008 fn configprops_redacts_sensitive_values() {
2009 let mut props = HashMap::new();
2010 ConfigProperties::track_property(
2011 &mut props,
2012 "database.url",
2013 "postgres://user:pass@host/db",
2014 "",
2015 "dev",
2016 );
2017 assert_eq!(props["database.url"].value, "****");
2018 }
2019
2020 #[test]
2021 fn configprops_tracks_default_source() {
2022 let mut props = HashMap::new();
2023 ConfigProperties::track_property(&mut props, "server.port", "3000", "3000", "dev");
2024 assert_eq!(props["server.port"].source, "default");
2025 assert_eq!(props["server.port"].value, "3000");
2026 }
2027
2028 #[test]
2029 fn configprops_tracks_profile_source() {
2030 let mut props = HashMap::new();
2031 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
2032 assert_eq!(props["log.level"].source, "profile_default:dev");
2033 }
2034
2035 #[tokio::test]
2038 async fn actuator_loggers_get_returns_levels() {
2039 let app = actuator_router(true).with_state(test_state());
2040 let resp = app
2041 .oneshot(
2042 Request::builder()
2043 .uri("/actuator/loggers")
2044 .body(Body::empty())
2045 .unwrap(),
2046 )
2047 .await
2048 .unwrap();
2049
2050 assert_eq!(resp.status(), StatusCode::OK);
2051 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2052 .await
2053 .unwrap();
2054 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2055 assert_eq!(json["current_level"], "info");
2056 assert!(json["available_levels"].is_array());
2057 }
2058
2059 #[tokio::test]
2060 async fn actuator_loggers_put_changes_level() {
2061 let state = test_state();
2062 let app = actuator_router(true).with_state(state.clone());
2063 let resp = app
2064 .oneshot(
2065 Request::builder()
2066 .method("PUT")
2067 .uri("/actuator/loggers/autumn_web")
2068 .header("content-type", "application/json")
2069 .body(Body::from(r#"{"level": "debug"}"#))
2070 .unwrap(),
2071 )
2072 .await
2073 .unwrap();
2074
2075 assert_eq!(resp.status(), StatusCode::OK);
2076 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2077 .await
2078 .unwrap();
2079 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2080 assert_eq!(json["status"], "ok");
2081 assert_eq!(json["message"], "Logger 'autumn_web' set to 'debug'");
2082
2083 let overrides = state.log_levels().logger_overrides();
2084 assert_eq!(
2085 overrides.get("autumn_web").map(String::as_str),
2086 Some("debug")
2087 );
2088 }
2089
2090 #[tokio::test]
2091 async fn actuator_loggers_put_rejects_invalid_level() {
2092 let app = actuator_router(true).with_state(test_state());
2093 let resp = app
2094 .oneshot(
2095 Request::builder()
2096 .method("PUT")
2097 .uri("/actuator/loggers/autumn_web")
2098 .header("content-type", "application/json")
2099 .body(Body::from(r#"{"level": "banana"}"#))
2100 .unwrap(),
2101 )
2102 .await
2103 .unwrap();
2104
2105 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2106 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2107 .await
2108 .unwrap();
2109 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2110 assert_eq!(json["status"], "error");
2111 }
2112
2113 #[tokio::test]
2114 async fn actuator_loggers_hidden_in_nonsensitive_mode() {
2115 let app = actuator_router(false).with_state(test_state());
2116 let resp = app
2117 .oneshot(
2118 Request::builder()
2119 .uri("/actuator/loggers")
2120 .body(Body::empty())
2121 .unwrap(),
2122 )
2123 .await
2124 .unwrap();
2125 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2126 }
2127
2128 #[test]
2129 fn log_levels_set_and_get() {
2130 let levels = LogLevels::new("info");
2131 assert_eq!(levels.current_level(), "info");
2132
2133 let _ = levels.set_logger_level("my_crate", "debug");
2134 let overrides = levels.logger_overrides();
2135 assert_eq!(overrides.get("my_crate").map(String::as_str), Some("debug"));
2136 }
2137
2138 #[test]
2139 fn log_levels_root_updates_current() {
2140 let levels = LogLevels::new("info");
2141 let prev = levels.set_logger_level("root", "trace");
2142 assert_eq!(prev, Some("info".to_string()));
2143 assert_eq!(levels.current_level(), "trace");
2144 }
2145
2146 #[tokio::test]
2149 async fn actuator_prometheus_returns_metrics() {
2150 let state = test_state();
2151 state.metrics().record("GET", "/test", 200, 10);
2152 state.metrics().record("POST", "/test", 500, 50);
2153
2154 let app = actuator_router(true).with_state(state);
2155 let resp = app
2156 .oneshot(
2157 Request::builder()
2158 .uri("/actuator/prometheus")
2159 .body(Body::empty())
2160 .unwrap(),
2161 )
2162 .await
2163 .unwrap();
2164
2165 assert_eq!(resp.status(), StatusCode::OK);
2166 assert_eq!(
2167 resp.headers().get("content-type").unwrap(),
2168 "text/plain; version=0.0.4"
2169 );
2170
2171 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2172 .await
2173 .unwrap();
2174 let text = String::from_utf8(body.to_vec()).unwrap();
2175
2176 assert!(text.contains("# HELP autumn_http_requests_total Total number of HTTP requests"));
2177 assert!(text.contains("# TYPE autumn_http_requests_total counter"));
2178 assert!(text.contains("autumn_http_requests_total 2"));
2179
2180 assert!(text.contains("autumn_http_requests_active "));
2181 assert!(text.contains("autumn_http_responses_total{status=\"2xx\"} 1"));
2182 assert!(text.contains("autumn_http_responses_total{status=\"5xx\"} 1"));
2183
2184 assert!(
2185 text.contains("autumn_http_route_requests_total{method=\"GET\",route=\"/test\"} 1")
2186 );
2187 assert!(
2188 text.contains("autumn_http_route_requests_total{method=\"POST\",route=\"/test\"} 1")
2189 );
2190 }
2191
2192 #[tokio::test]
2193 async fn actuator_prometheus_hidden_in_nonsensitive_mode() {
2194 let app = actuator_router(false).with_state(test_state());
2195 let resp = app
2196 .oneshot(
2197 Request::builder()
2198 .uri("/actuator/prometheus")
2199 .body(Body::empty())
2200 .unwrap(),
2201 )
2202 .await
2203 .unwrap();
2204 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2205 }
2206
2207 #[tokio::test]
2210 async fn actuator_tasks_returns_registered_tasks() {
2211 let state = test_state();
2212 state.task_registry().register("cleanup", "every 5m");
2213 state.task_registry().record_start("cleanup");
2214 state.task_registry().record_success("cleanup", 150);
2215
2216 let app = actuator_router(true).with_state(state);
2217 let resp = app
2218 .oneshot(
2219 Request::builder()
2220 .uri("/actuator/tasks")
2221 .body(Body::empty())
2222 .unwrap(),
2223 )
2224 .await
2225 .unwrap();
2226
2227 assert_eq!(resp.status(), StatusCode::OK);
2228 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2229 .await
2230 .unwrap();
2231 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2232 let task = &json["scheduled_tasks"]["cleanup"];
2233 assert_eq!(task["schedule"], "every 5m");
2234 assert_eq!(task["status"], "idle");
2235 assert_eq!(task["total_runs"], 1);
2236 assert_eq!(task["total_failures"], 0);
2237 assert_eq!(task["last_result"], "ok");
2238 assert_eq!(task["last_duration_ms"], 150);
2239 }
2240
2241 #[tokio::test]
2242 async fn actuator_jobs_returns_registered_jobs() {
2243 let state = test_state();
2244 state.job_registry().register("send_email");
2245 state.job_registry().record_enqueue("send_email");
2246 state.job_registry().record_start("send_email");
2247 state.job_registry().record_success("send_email");
2248
2249 let app = actuator_router(true).with_state(state);
2250 let resp = app
2251 .oneshot(
2252 Request::builder()
2253 .uri("/actuator/jobs")
2254 .body(Body::empty())
2255 .unwrap(),
2256 )
2257 .await
2258 .unwrap();
2259
2260 assert_eq!(resp.status(), StatusCode::OK);
2261 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2262 .await
2263 .unwrap();
2264 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2265 let job = &json["jobs"]["send_email"];
2266 assert_eq!(job["queued"], 0);
2267 assert_eq!(job["in_flight"], 0);
2268 assert_eq!(job["total_successes"], 1);
2269 assert_eq!(job["total_failures"], 0);
2270 }
2271
2272 #[cfg(feature = "ws")]
2273 #[tokio::test]
2274 async fn actuator_channels_returns_metrics() {
2275 let state = test_state();
2276 let mut rx = state.channels().subscribe("feed");
2277 state
2278 .channels()
2279 .broadcast()
2280 .publish("feed", "hello")
2281 .expect("publish should succeed");
2282 rx.try_recv().expect("subscriber should receive payload");
2283
2284 let app = actuator_router(true).with_state(state);
2285 let resp = app
2286 .oneshot(
2287 Request::builder()
2288 .uri("/actuator/channels")
2289 .body(Body::empty())
2290 .unwrap(),
2291 )
2292 .await
2293 .unwrap();
2294
2295 assert_eq!(resp.status(), StatusCode::OK);
2296 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2297 .await
2298 .unwrap();
2299 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2300 let feed = &json["channels"]["feed"];
2301 assert_eq!(feed["subscriber_count"], 1);
2302 assert_eq!(feed["lifetime_publish_count"], 1);
2303 assert_eq!(feed["dropped_count"], 0);
2304 assert_eq!(feed["lagged_count"], 0);
2305 }
2306
2307 #[tokio::test]
2308 async fn actuator_tasks_hidden_in_nonsensitive_mode() {
2309 let app = actuator_router(false).with_state(test_state());
2310 let resp = app
2311 .oneshot(
2312 Request::builder()
2313 .uri("/actuator/tasks")
2314 .body(Body::empty())
2315 .unwrap(),
2316 )
2317 .await
2318 .unwrap();
2319 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2320 }
2321
2322 #[test]
2323 fn task_registry_records_failure() {
2324 let registry = TaskRegistry::new();
2325 registry.register("my_task", "cron 0 * * * *");
2326 registry.record_start("my_task");
2327 registry.record_failure("my_task", 200, "connection refused");
2328
2329 let snapshot = registry.snapshot();
2330 let task = &snapshot["my_task"];
2331 assert_eq!(task.status, "idle");
2332 assert_eq!(task.total_runs, 1);
2333 assert_eq!(task.total_failures, 1);
2334 assert_eq!(task.last_result.as_deref(), Some("failed"));
2335 assert_eq!(task.last_error.as_deref(), Some("connection refused"));
2336 }
2337
2338 #[test]
2339 fn task_registry_empty_snapshot() {
2340 let registry = TaskRegistry::new();
2341 assert!(registry.snapshot().is_empty());
2342 }
2343 #[test]
2344 fn log_levels_rejects_new_key_at_capacity() {
2345 let levels = LogLevels::new("info");
2346 for i in 0..1000 {
2348 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
2349 }
2350
2351 let result = levels.set_logger_level("logger_1000", "warn");
2353 assert_eq!(result, None);
2354 assert_eq!(levels.logger_overrides().len(), 1000);
2355 assert_eq!(levels.logger_overrides().get("logger_1000"), None);
2356 }
2357
2358 #[test]
2359 fn log_levels_accepts_existing_key_at_capacity() {
2360 let levels = LogLevels::new("info");
2361 for i in 0..1000 {
2363 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
2364 }
2365
2366 let prev = levels.set_logger_level("logger_999", "warn");
2368 assert_eq!(prev.as_deref(), Some("debug"));
2369 assert_eq!(levels.logger_overrides().len(), 1000);
2370 assert_eq!(
2371 levels
2372 .logger_overrides()
2373 .get("logger_999")
2374 .map(String::as_str),
2375 Some("warn")
2376 );
2377 }
2378
2379 #[test]
2380 fn task_registry_records_multiple_successes_and_failures() {
2381 let registry = TaskRegistry::new();
2382 registry.register("my_task", "cron * * * * *");
2383
2384 registry.record_start("my_task");
2386 registry.record_success("my_task", 100);
2387
2388 registry.record_start("my_task");
2390 registry.record_success("my_task", 110);
2391
2392 let snapshot = registry.snapshot();
2393 let task = &snapshot["my_task"];
2394 assert_eq!(task.total_runs, 2);
2395 assert_eq!(task.total_failures, 0);
2396
2397 registry.record_start("my_task");
2399 registry.record_failure("my_task", 50, "failed");
2400
2401 let snapshot2 = registry.snapshot();
2402 let task2 = &snapshot2["my_task"];
2403 assert_eq!(task2.total_runs, 3);
2404 assert_eq!(task2.total_failures, 1);
2405 }
2406
2407 #[test]
2408 fn configprops_tracks_custom_profile() {
2409 let mut props = HashMap::new();
2410 ConfigProperties::track_property(
2411 &mut props,
2412 "log.level",
2413 "debug",
2414 "info",
2415 "custom_profile",
2416 );
2417 assert_eq!(props["log.level"].source, "autumn.toml");
2418 }
2419
2420 #[test]
2421 fn configprops_tracks_dev_prod_profiles() {
2422 let mut props = HashMap::new();
2423 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
2424 assert_eq!(props["log.level"].source, "profile_default:dev");
2425
2426 ConfigProperties::track_property(&mut props, "log.format", "json", "text", "prod");
2427 assert_eq!(props["log.format"].source, "profile_default:prod");
2428 }
2429
2430 #[test]
2431 fn configprops_returns_default_when_values_match() {
2432 let mut props = HashMap::new();
2433 ConfigProperties::track_property(&mut props, "log.level", "info", "info", "dev");
2434 assert_eq!(props["log.level"].source, "default");
2435 }
2436
2437 #[tokio::test]
2438 async fn actuator_ui_dashboard_returns_html_or_unimplemented() {
2439 let app = actuator_router(true).with_state(test_state());
2440
2441 let res = app
2442 .oneshot(
2443 Request::builder()
2444 .uri("/actuator/ui")
2445 .body(Body::empty())
2446 .unwrap(),
2447 )
2448 .await
2449 .unwrap();
2450
2451 if cfg!(feature = "maud") {
2452 assert_eq!(res.status(), StatusCode::OK);
2453 assert_eq!(
2454 res.headers().get("content-type").unwrap(),
2455 "text/html; charset=utf-8"
2456 );
2457 } else {
2458 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
2459 }
2460 }
2461
2462 #[tokio::test]
2463 async fn actuator_ui_metrics_returns_html_or_unimplemented() {
2464 let app = actuator_router(true).with_state(test_state());
2465
2466 let res = app
2467 .oneshot(
2468 Request::builder()
2469 .uri("/actuator/ui/metrics")
2470 .body(Body::empty())
2471 .unwrap(),
2472 )
2473 .await
2474 .unwrap();
2475
2476 if cfg!(feature = "maud") {
2477 assert_eq!(res.status(), StatusCode::OK);
2478 assert_eq!(
2479 res.headers().get("content-type").unwrap(),
2480 "text/html; charset=utf-8"
2481 );
2482 } else {
2483 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
2484 }
2485 }
2486
2487 #[tokio::test]
2488 async fn actuator_ui_tasks_returns_html_or_unimplemented() {
2489 let app = actuator_router(true).with_state(test_state());
2490
2491 let res = app
2492 .oneshot(
2493 Request::builder()
2494 .uri("/actuator/ui/tasks")
2495 .body(Body::empty())
2496 .unwrap(),
2497 )
2498 .await
2499 .unwrap();
2500
2501 if cfg!(feature = "maud") {
2502 assert_eq!(res.status(), StatusCode::OK);
2503 assert_eq!(
2504 res.headers().get("content-type").unwrap(),
2505 "text/html; charset=utf-8"
2506 );
2507 } else {
2508 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
2509 }
2510 }
2511
2512 #[tokio::test]
2513 async fn test_actuator_router_calls_prefix_variant() {
2514 let app = actuator_router(false).with_state(test_state());
2517 let resp = app
2518 .oneshot(
2519 Request::builder()
2520 .uri("/actuator/health")
2521 .body(Body::empty())
2522 .unwrap(),
2523 )
2524 .await
2525 .unwrap();
2526
2527 assert_eq!(resp.status(), StatusCode::OK);
2528 }
2529
2530 #[tokio::test]
2533 async fn actuator_a11y_returns_posture_json() {
2534 let app = actuator_router(false).with_state(test_state());
2535 let resp = app
2536 .oneshot(
2537 Request::builder()
2538 .uri("/actuator/a11y")
2539 .body(Body::empty())
2540 .unwrap(),
2541 )
2542 .await
2543 .unwrap();
2544
2545 assert_eq!(resp.status(), StatusCode::OK);
2546 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2547 .await
2548 .unwrap();
2549 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2550 assert!(json["lang_set"].is_boolean(), "{json}");
2551 assert!(json["skip_link_present"].is_boolean(), "{json}");
2552 assert!(json["landmark_regions_present"].is_boolean(), "{json}");
2553 }
2554
2555 #[tokio::test]
2556 async fn actuator_a11y_available_in_nonsensitive_mode() {
2557 let app = actuator_router(false).with_state(test_state());
2558 let resp = app
2559 .oneshot(
2560 Request::builder()
2561 .uri("/actuator/a11y")
2562 .body(Body::empty())
2563 .unwrap(),
2564 )
2565 .await
2566 .unwrap();
2567 assert_eq!(resp.status(), StatusCode::OK);
2568 }
2569
2570 #[tokio::test]
2571 async fn actuator_a11y_posture_default_values() {
2572 let app = actuator_router(true).with_state(test_state());
2573 let resp = app
2574 .oneshot(
2575 Request::builder()
2576 .uri("/actuator/a11y")
2577 .body(Body::empty())
2578 .unwrap(),
2579 )
2580 .await
2581 .unwrap();
2582
2583 assert_eq!(resp.status(), StatusCode::OK);
2584 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2585 .await
2586 .unwrap();
2587 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2588 assert_eq!(json["lang_set"], false, "{json}");
2590 assert_eq!(json["skip_link_present"], false, "{json}");
2591 assert_eq!(json["landmark_regions_present"], false, "{json}");
2592 }
2593
2594 #[test]
2595 fn a11y_posture_all_passing_is_compliant() {
2596 let posture = A11yPosture {
2597 lang_set: true,
2598 skip_link_present: true,
2599 landmark_regions_present: true,
2600 };
2601 assert!(posture.is_compliant());
2602 }
2603
2604 #[test]
2605 fn a11y_posture_missing_lang_is_not_compliant() {
2606 let posture = A11yPosture {
2607 lang_set: false,
2608 skip_link_present: true,
2609 landmark_regions_present: true,
2610 };
2611 assert!(!posture.is_compliant());
2612 }
2613
2614 #[tokio::test]
2615 async fn actuator_a11y_endpoint_paths_includes_a11y() {
2616 let paths = actuator_endpoint_paths("/actuator", false);
2617 assert!(
2618 paths.iter().any(|p| p == "/actuator/a11y"),
2619 "a11y path not found in: {paths:?}"
2620 );
2621 }
2622}
2623
2624#[cfg(test)]
2625mod havoc_proptest {
2626 use super::*;
2627 use proptest::prelude::*;
2628
2629 proptest! {
2630 #![proptest_config(ProptestConfig::with_cases(1))]
2631 #[test]
2632 fn log_levels_memory_exhaustion(names in proptest::collection::vec(".*", 5000)) {
2633 let levels = LogLevels::new("info");
2634 for name in names {
2635 let _ = levels.set_logger_level(&name, "debug");
2636 }
2637 assert!(levels.logger_overrides().len() <= 1000, "Memory leak: unbounded loggers inserted");
2638 }
2639 }
2640}
2641
2642#[cfg(all(feature = "maud", feature = "htmx"))]
2645async fn ui_dashboard() -> impl IntoResponse {
2646 let html = maud::html! {
2647 (maud::DOCTYPE)
2648 html lang="en" {
2649 head {
2650 meta charset="utf-8";
2651 meta name="viewport" content="width=device-width, initial-scale=1";
2652 title { "Autumn Actuator Dashboard" }
2653 script src="/static/js/htmx.min.js" {}
2654 style {
2655 (crate::ui::tokens::TOKENS_CSS)
2656 "body { font-family: var(--font-family); background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }"
2657 "h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }"
2658 ".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }"
2659 ".card { background: var(--surface); padding: 1.5rem; border-radius: var(--radius); box-shadow: var(--shadow); }"
2660 ".card h2 { font-size: 1.125rem; font-weight: 500; margin-top: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }"
2661 ".stat { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }"
2662 ".stat-label { color: var(--text-muted); }"
2663 ".stat-value { font-weight: 500; }"
2664 ".task-item { border: 1px solid var(--border); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 0.75rem; }"
2665 ".task-name { font-weight: 600; display: block; margin-bottom: 0.25rem; }"
2666 ".task-meta { font-size: 0.875rem; color: var(--text-muted); }"
2667 ".badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }"
2668 ".badge-green { background: #dcfce7; color: #166534; }"
2669 ".badge-gray { background: #f3f4f6; color: #374151; }"
2670 ".badge-red { background: #fee2e2; color: #991b1b; }"
2671 }
2672 }
2673 body {
2674 h1 { "🍂 Autumn Actuator Dashboard" }
2675 div class="grid" {
2676 div class="card" hx-get="ui/metrics" hx-trigger="load, every 2s" {
2677 "Loading metrics..."
2678 }
2679 div class="card" hx-get="ui/tasks" hx-trigger="load, every 2s" {
2680 "Loading tasks..."
2681 }
2682 }
2683 }
2684 }
2685 };
2686 (
2687 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
2688 html.into_string(),
2689 )
2690}
2691
2692#[cfg(not(all(feature = "maud", feature = "htmx")))]
2693async fn ui_dashboard() -> impl IntoResponse {
2694 (
2695 StatusCode::NOT_IMPLEMENTED,
2696 "Maud feature is required for the UI dashboard",
2697 )
2698}
2699
2700#[cfg(all(feature = "maud", feature = "htmx"))]
2701async fn ui_metrics<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
2702 let metrics = state.metrics().snapshot();
2703 let uptime = state.uptime_display();
2704
2705 let html = maud::html! {
2706 h2 { "System Metrics" }
2707 div class="stat" {
2708 span class="stat-label" { "Uptime" }
2709 span class="stat-value" { (uptime) }
2710 }
2711 div class="stat" {
2712 span class="stat-label" { "Total Requests" }
2713 span class="stat-value" { (metrics.http.requests_total) }
2714 }
2715 div class="stat" {
2716 span class="stat-label" { "Active Requests" }
2717 span class="stat-value" { (metrics.http.requests_active) }
2718 }
2719 div class="stat" {
2720 span class="stat-label" { "P95 Latency" }
2721 span class="stat-value" { (metrics.http.latency_ms.p95) " ms" }
2722 }
2723 div class="stat" {
2724 span class="stat-label" { "P99 Latency" }
2725 span class="stat-value" { (metrics.http.latency_ms.p99) " ms" }
2726 }
2727 };
2728 (
2729 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
2730 html.into_string(),
2731 )
2732}
2733
2734#[cfg(not(all(feature = "maud", feature = "htmx")))]
2735async fn ui_metrics<S: ProvideActuatorState>() -> impl IntoResponse {
2736 (
2737 StatusCode::NOT_IMPLEMENTED,
2738 "Maud feature is required for the UI dashboard",
2739 )
2740}
2741
2742#[cfg(all(feature = "maud", feature = "htmx"))]
2743async fn ui_tasks<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
2744 let tasks = state.task_registry().snapshot();
2745
2746 let html = maud::html! {
2747 h2 { "Background Tasks" }
2748 @if tasks.is_empty() {
2749 p class="stat-label" { "No tasks registered." }
2750 } @else {
2751 @for (name, task) in tasks.iter() {
2752 div class="task-item" {
2753 span class="task-name" { (name) }
2754 div class="task-meta" {
2755 @if task.status == "running" {
2756 span class="badge badge-green" { "Running" }
2757 } @else {
2758 span class="badge badge-gray" { "Idle" }
2759 }
2760 " "
2761 "Runs: " (task.total_runs)
2762 @if task.total_failures > 0 {
2763 " " span class="badge badge-red" { "Failures: " (task.total_failures) }
2764 }
2765 }
2766 }
2767 }
2768 }
2769 };
2770 (
2771 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
2772 html.into_string(),
2773 )
2774}
2775
2776#[cfg(not(all(feature = "maud", feature = "htmx")))]
2777async fn ui_tasks<S: ProvideActuatorState>() -> impl IntoResponse {
2778 (
2779 StatusCode::NOT_IMPLEMENTED,
2780 "Maud feature is required for the UI dashboard",
2781 )
2782}