Skip to main content

autumn_web/
actuator.rs

1//! Actuator endpoints for operational observability.
2//!
3//! Provides health, info, env, metrics, configprops, loggers, and tasks
4//! endpoints under the configured actuator prefix.
5//!
6//! Sensitive endpoints are gated by profile-aware defaults:
7//! - **dev**: all endpoints enabled
8//! - **prod**: only health, info, and metrics
9
10use 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/// Scaffold-level accessibility posture reported by `/actuator/a11y`.
20///
21/// Each field indicates whether a foundational WCAG 2.1 AA scaffold concern is
22/// addressed in the application.  Apps generated with `autumn new` satisfy all
23/// three by default; existing apps can opt in incrementally.
24#[derive(Debug, Clone, Serialize, Default)]
25pub struct A11yPosture {
26    /// `<html lang="…">` is set in the page template.
27    pub lang_set: bool,
28    /// A skip-to-content link is present as the first focusable element.
29    pub skip_link_present: bool,
30    /// Semantic landmark regions (`<header>`, `<main>`, `<nav>`, `<footer>`)
31    /// are used in the page layout.
32    pub landmark_regions_present: bool,
33}
34
35impl A11yPosture {
36    /// Returns `true` when all scaffold-level a11y concerns are addressed.
37    #[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
43/// Trait to abstract the state requirements for actuator handlers.
44///
45/// Implement this trait on your application's state type to provide
46/// the necessary dependencies for actuator endpoints (e.g. `/actuator/metrics`).
47/// This avoids tight coupling between the actuator middleware and the specific `AppState`.
48pub trait ProvideActuatorState {
49    /// Returns a reference to the [`crate::middleware::MetricsCollector`]
50    /// tracking current HTTP traffic metrics.
51    fn metrics(&self) -> &crate::middleware::MetricsCollector;
52
53    /// Returns a reference to the dynamic [`LogLevels`] configuration
54    /// allowing runtime adjustment of `tracing` filters.
55    fn log_levels(&self) -> &LogLevels;
56
57    /// Returns a reference to the [`TaskRegistry`] holding status and metadata
58    /// for async scheduled background tasks.
59    fn task_registry(&self) -> &TaskRegistry;
60
61    /// Returns a reference to the [`JobRegistry`] holding queue and failure
62    /// information for ad-hoc background jobs.
63    fn job_registry(&self) -> &JobRegistry;
64
65    /// Returns a reference to the [`ConfigProperties`] snapshot, providing
66    /// active configuration state for the environment endpoint.
67    fn config_props(&self) -> &ConfigProperties;
68
69    /// Returns the currently active execution profile (e.g. "dev", "prod")
70    /// which modifies what sensitive endpoints are exposed.
71    fn profile(&self) -> &str;
72
73    /// Returns a human-readable string displaying how long the application
74    /// has been running (e.g., "2d 4h 13m").
75    fn uptime_display(&self) -> String;
76
77    /// Returns a reference to the system [`crate::channels::Channels`] which
78    /// broadcasts operational events to WebSocket streams.
79    #[cfg(feature = "ws")]
80    fn channels(&self) -> &crate::channels::Channels;
81
82    /// Returns the main cancellation token that triggers a graceful framework shutdown.
83    #[cfg(feature = "ws")]
84    fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
85
86    /// Returns an optional reference to the database connection pool,
87    /// used to expose database connection metrics in the `/actuator/metrics` endpoint.
88    #[cfg(feature = "db")]
89    fn pool(
90        &self,
91    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>>;
92
93    /// Returns the scaffold-level accessibility posture reported by `/actuator/a11y`.
94    ///
95    /// Override this in your `AppState` implementation to declare which
96    /// WCAG 2.1 AA scaffold concerns your application addresses.  The default
97    /// returns all-false (no concerns addressed) — a conservative safe default.
98    fn a11y_posture(&self) -> A11yPosture {
99        A11yPosture::default()
100    }
101}
102
103// ── Shared types for AppState ──────────────────────────────────
104
105/// Runtime log level management for the loggers actuator endpoint.
106///
107/// Stores the current effective log level and per-logger overrides.
108/// Changes are ephemeral -- they reset on restart.
109#[derive(Clone)]
110pub struct LogLevels {
111    inner: Arc<RwLock<LogLevelsInner>>,
112}
113
114struct LogLevelsInner {
115    /// The current global log level.
116    current_level: String,
117    /// Per-logger level overrides applied at runtime.
118    logger_overrides: HashMap<String, String>,
119}
120
121impl LogLevels {
122    /// Create a new `LogLevels` with the given initial level.
123    #[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    /// Get the current global log level.
134    #[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    /// Get all per-logger overrides.
142    #[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    /// Set the level for a specific logger. Returns the previous level if any.
151    #[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        // Prevent unbounded memory growth from arbitrary logger names
157        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 setting the root level, update current_level too
166        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/// Scheduled task status information.
184#[derive(Debug, Clone, Serialize)]
185pub struct TaskStatus {
186    /// The schedule description (e.g., "every 5m" or "cron 0 0 * * *").
187    pub schedule: String,
188    /// Whether this task is coordinated across the fleet or per replica.
189    pub coordination: crate::task::TaskCoordination,
190    /// Scheduler backend currently coordinating this task.
191    pub scheduler_backend: String,
192    /// Replica id for this process.
193    pub replica_id: String,
194    /// Replica id that last acquired leadership for this task.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub current_leader: Option<String>,
197    /// Last global tick key observed for this task.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub last_tick: Option<String>,
200    /// Last time this task fired (ISO 8601), if ever.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub last_fired_at: Option<String>,
203    /// Next scheduled run time (ISO 8601), if known.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub next_run_at: Option<String>,
206    /// Current task state.
207    pub status: String,
208    /// Last time the task ran (ISO 8601), if ever.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub last_run: Option<String>,
211    /// Duration of last run in milliseconds.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub last_duration_ms: Option<u64>,
214    /// Result of last run.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub last_result: Option<String>,
217    /// Last error message, if the task failed.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub last_error: Option<String>,
220    /// Total number of times the task has run.
221    pub total_runs: u64,
222    /// Total number of failures.
223    pub total_failures: u64,
224}
225
226/// Registry of scheduled tasks and their runtime status.
227#[derive(Clone)]
228pub struct TaskRegistry {
229    inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
230}
231
232/// On-demand background job status information.
233#[derive(Debug, Clone, Serialize)]
234pub struct JobStatus {
235    /// Approximate queued jobs waiting to run.
236    pub queued: u64,
237    /// Number of currently running jobs.
238    pub in_flight: u64,
239    /// Total successful executions.
240    pub total_successes: u64,
241    /// Total failed executions.
242    pub total_failures: u64,
243    /// Total dead-lettered executions.
244    pub dead_letters: u64,
245    /// Last observed error for this job, if any.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub last_error: Option<String>,
248}
249
250/// Registry of ad-hoc jobs and their runtime status.
251#[derive(Clone)]
252pub struct JobRegistry {
253    inner: Arc<RwLock<HashMap<String, JobStatus>>>,
254}
255
256impl JobRegistry {
257    /// Create a new empty job registry.
258    #[must_use]
259    pub fn new() -> Self {
260        Self {
261            inner: Arc::new(RwLock::new(HashMap::new())),
262        }
263    }
264
265    /// Register a job name with initial counters.
266    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    /// Record that a new job instance was enqueued.
280    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    /// Record that a queued job started execution.
295    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    /// Record that a queued job was canceled before execution.
305    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    /// Record a successful execution.
314    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    /// Record a retriable failure.
325    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    /// Record a terminal failure.
335    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    /// Snapshot all registered jobs.
349    #[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    /// Create a new empty task registry.
363    #[must_use]
364    pub fn new() -> Self {
365        Self {
366            inner: Arc::new(RwLock::new(HashMap::new())),
367        }
368    }
369
370    /// Register a task with its schedule description.
371    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    /// Register a scheduled task with scheduler coordination metadata.
382    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    /// Record the replica that acquired leadership for a global task tick.
416    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    /// Record that a task started running.
428    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    /// Record the next scheduled run time for an idle task.
440    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    /// Record that a task completed successfully.
451    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    /// Record that a task failed.
469    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    /// Get a snapshot of all task statuses.
488    #[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/// Resolved config property with source provenance.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct ConfigProperty {
514    /// The resolved value (redacted if sensitive).
515    pub value: serde_json::Value,
516    /// Where the value came from.
517    pub source: String,
518}
519
520/// Collection of resolved config properties with source tracking.
521#[derive(Debug, Clone, Default)]
522pub struct ConfigProperties {
523    inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
524}
525
526impl ConfigProperties {
527    /// Build config properties with source tracking from the loaded config.
528    #[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        // Avoids dynamic reallocation since we know roughly how many config properties are tracked.
535        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        // Check if there's an env var override
910        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    /// Get a snapshot of all properties.
937    #[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// ── Health ──────────────────────────────────────────────────────
947
948/// Enhanced health response for the actuator health endpoint.
949#[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
973/// `GET <actuator-prefix>/health`
974pub 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// ── Info ────────────────────────────────────────────────────────
1027
1028/// Application info response.
1029#[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
1053/// `GET <actuator-prefix>/info`
1054pub(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// ── Env (sensitive) ─────────────────────────────────────────────
1073
1074/// Config environment response with redacted secrets.
1075#[derive(Serialize)]
1076pub(crate) struct ActuatorEnv {
1077    active_profile: String,
1078    properties: std::collections::HashMap<String, serde_json::Value>,
1079}
1080
1081/// Keys that trigger value redaction.
1082const 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
1097/// `GET /actuator/env` — only available when actuator sensitive mode is enabled.
1098pub(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
1114// ── Metrics ────────────────────────────────────────────────────
1115
1116/// `GET <actuator-prefix>/metrics` -- request metrics, latency, status codes, DB pool stats.
1117pub(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    // Include DB pool stats if available
1124    #[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
1144// ── Prometheus ─────────────────────────────────────────────────
1145
1146/// `GET <actuator-prefix>/prometheus` -- export metrics in Prometheus format.
1147pub(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    // requests_total
1156    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    // requests_active
1165    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    // by_status
1174    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    // by_route
1198    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            // route_key is formatted as "METHOD /path"
1203            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
1222// ── Config Properties (sensitive) ──────────────────────────────
1223
1224/// `GET <actuator-prefix>/configprops` -- all config properties with source tracking.
1225pub(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
1236// ── Loggers (sensitive) ────────────────────────────────────────
1237
1238/// Available log levels for the loggers endpoint.
1239const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
1240
1241/// Response for `GET <actuator-prefix>/loggers`.
1242#[derive(Serialize)]
1243pub(crate) struct LoggersResponse {
1244    current_level: String,
1245    available_levels: Vec<&'static str>,
1246    loggers: HashMap<String, String>,
1247}
1248
1249/// `GET <actuator-prefix>/loggers` -- view current log levels.
1250pub(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/// Request body for `PUT <actuator-prefix>/loggers/{name}`.
1261#[derive(Deserialize)]
1262pub(crate) struct SetLoggerRequest {
1263    level: String,
1264}
1265
1266/// `PUT <actuator-prefix>/loggers/{name}` -- change a logger's level at runtime.
1267pub(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    // Validate the level
1275    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
1301// ── Tasks (sensitive) ──────────────────────────────────────────
1302
1303/// `GET <actuator-prefix>/tasks` -- scheduled task status.
1304pub(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
1314/// `GET <actuator-prefix>/jobs` -- ad-hoc background job status.
1315pub(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
1322// ── A11y ───────────────────────────────────────────────────────
1323
1324/// `GET <actuator-prefix>/a11y` -- scaffold-level accessibility posture.
1325///
1326/// Returns a JSON object describing which WCAG 2.1 AA scaffold concerns the
1327/// application addresses.  Available in all profiles (like `/actuator/health`).
1328pub(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// ── Channels (sensitive) ───────────────────────────────────────
1335
1336/// `GET <actuator-prefix>/channels` -- get current channel snapshots.
1337#[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// ── Tasks Stream (WebSocket) ───────────────────────────────────
1348
1349/// `GET <actuator-prefix>/tasks/stream` -- stream scheduled task events.
1350#[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
1383// ── Router builder ──────────────────────────────────────────────
1384
1385pub(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
1445/// Build the actuator router with profile-aware endpoint exposure.
1446///
1447/// In dev mode (or when `actuator.sensitive = true`), all endpoints are
1448/// exposed. In prod mode, only health, info, and metrics are available.
1449pub 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
1455/// Build the actuator router at a configured prefix.
1456///
1457/// This is the prefix-aware variant used by the framework router.
1458pub(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    // Nova: Add HTMX UI endpoints available unconditionally like metrics
1540    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    // ── Metrics endpoint tests ─────────────────────────────────
1889
1890    #[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    // ── Config properties endpoint tests ───────────────────────
1969
1970    #[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    // ── Loggers endpoint tests ─────────────────────────────────
2036
2037    #[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    // ── Prometheus endpoint tests ──────────────────────────────
2147
2148    #[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    // ── Tasks endpoint tests ───────────────────────────────────
2208
2209    #[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        // Fill to capacity
2347        for i in 0..1000 {
2348            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
2349        }
2350
2351        // Try to add a new key, should be rejected
2352        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        // Fill to capacity
2362        for i in 0..1000 {
2363            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
2364        }
2365
2366        // Try to update an existing key, should succeed
2367        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        // 1st success
2385        registry.record_start("my_task");
2386        registry.record_success("my_task", 100);
2387
2388        // 2nd success
2389        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        // 1st failure
2398        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        // The `actuator_router` function is a convenience wrapper around `actuator_router_with_prefix`
2515        // using "/actuator" as the prefix. We can test it by building it and hitting one of the endpoints.
2516        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    // ── RED: /actuator/a11y endpoint ───────────────────────────────
2531
2532    #[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        // Default test state should report false for all posture fields
2589        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// ── Nova: Actuator HTMX Dashboard UI ──────────────────────────
2643
2644#[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}