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}