Skip to main content

awa_ui/
state.rs

1use std::time::Duration;
2
3use awa_metrics::AwaMetrics;
4use serde::Serialize;
5use sqlx::PgPool;
6
7use crate::cache::DashboardCache;
8use crate::error::ApiError;
9
10#[derive(Clone)]
11pub struct AppState {
12    pub pool: PgPool,
13    pub read_only: bool,
14    pub cache: DashboardCache,
15    pub callback_hmac_secret: Option<[u8; 32]>,
16    /// Suggested frontend poll interval — at least as long as the cache TTL
17    /// so clients don't poll faster than the cache can refresh.
18    pub poll_interval_ms: u64,
19    /// Cached `AwaMetrics` handle. `AwaMetrics::from_global()` rebuilds the
20    /// full instrument set on every call, so we construct it once here and
21    /// share it across request handlers.
22    pub metrics: AwaMetrics,
23}
24
25impl AppState {
26    pub fn new(
27        pool: PgPool,
28        read_only: bool,
29        cache_ttl: Duration,
30        callback_hmac_secret: Option<[u8; 32]>,
31    ) -> Self {
32        let poll_interval_ms = cache_ttl.as_millis().max(5_000) as u64;
33        Self {
34            pool,
35            read_only,
36            cache: DashboardCache::new(cache_ttl),
37            callback_hmac_secret,
38            poll_interval_ms,
39            metrics: AwaMetrics::from_global(),
40        }
41    }
42
43    pub fn require_writable(&self) -> Result<(), ApiError> {
44        if self.read_only {
45            return Err(ApiError::read_only());
46        }
47        Ok(())
48    }
49
50    /// UI mutations change multiple dashboard surfaces, and the server caches
51    /// those aggregate endpoints independently. Invalidate them as a unit so
52    /// follow-up reads do not serve stale queue/job state after a mutation.
53    pub fn invalidate_dashboard_caches(&self) {
54        self.cache.stats.invalidate_all();
55        self.cache.queues.invalidate_all();
56        self.cache.runtime.invalidate_all();
57        self.cache.queue_runtime.invalidate_all();
58        self.cache.storage.invalidate_all();
59    }
60}
61
62pub async fn detect_read_only(pool: &PgPool) -> Result<bool, sqlx::Error> {
63    let read_only =
64        sqlx::query_scalar::<_, bool>("SELECT current_setting('transaction_read_only') = 'on'")
65            .fetch_one(pool)
66            .await?;
67    Ok(read_only)
68}
69
70/// How the API server decides whether to accept mutation requests.
71///
72/// `Auto` (the default) probes the Postgres connection — useful for running
73/// the UI against a read replica or a read-only role without extra config.
74/// Forcing `ReadOnly` is the operator escape hatch when the DB is writable
75/// but mutations should still be disabled (e.g. an incident read-out, a
76/// shared debugging instance, a less-trusted public UI session).
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub enum ReadOnlyMode {
79    /// Probe the DB connection — read-only if Postgres reports
80    /// `transaction_read_only = on`, writable otherwise.
81    #[default]
82    Auto,
83    /// Force read-only regardless of DB privilege. Mutation endpoints return
84    /// 503 (the same status the auto-detect path uses) and
85    /// `/api/capabilities` reports `read_only: true`.
86    ReadOnly,
87    /// Force writable. Mutations are always permitted at the HTTP layer; the
88    /// DB still has the final say at query time.
89    Writable,
90}
91
92impl ReadOnlyMode {
93    /// Resolve to a concrete boolean, probing the pool only when `Auto`.
94    pub async fn resolve(self, pool: &PgPool) -> Result<bool, sqlx::Error> {
95        match self {
96            Self::Auto => detect_read_only(pool).await,
97            Self::ReadOnly => Ok(true),
98            Self::Writable => Ok(false),
99        }
100    }
101}
102
103#[derive(Debug, Serialize)]
104pub struct Capabilities {
105    pub read_only: bool,
106    /// Suggested polling interval in milliseconds. The server sets this
107    /// higher for read-only replicas where data is inherently stale.
108    pub poll_interval_ms: u64,
109}