Skip to main content

awa_ui/
lib.rs

1pub mod cache;
2pub mod error;
3pub mod handlers;
4pub mod state;
5
6use std::time::Duration;
7
8use axum::http::header;
9use axum::response::{Html, IntoResponse, Response};
10use axum::routing::{get, post};
11use axum::Router;
12use rust_embed::Embed;
13use sqlx::PgPool;
14use tower_http::cors::CorsLayer;
15
16use crate::state::{AppState, ReadOnlyMode};
17
18#[derive(Embed)]
19#[folder = "static/"]
20struct StaticAssets;
21
22/// Create the awa-ui router with all API routes and static file serving.
23///
24/// Read-only mode is auto-detected by probing the database connection — see
25/// [`router_with`] to force it explicitly.
26pub async fn router(pool: PgPool, cache_ttl: Duration) -> Result<Router, sqlx::Error> {
27    router_with(pool, cache_ttl, None, ReadOnlyMode::Auto).await
28}
29
30/// Create the awa-ui router with optional callback signature verification.
31///
32/// Read-only mode is auto-detected. Prefer [`router_with`] when you need to
33/// force read-only or writable behaviour regardless of DB privilege.
34pub async fn router_with_callback_secret(
35    pool: PgPool,
36    cache_ttl: Duration,
37    callback_hmac_secret: Option<[u8; 32]>,
38) -> Result<Router, sqlx::Error> {
39    router_with(pool, cache_ttl, callback_hmac_secret, ReadOnlyMode::Auto).await
40}
41
42/// Create the awa-ui router with full control over callback signing and
43/// read-only behaviour.
44///
45/// `read_only_mode`:
46/// - [`ReadOnlyMode::Auto`] — probe the DB (matches legacy behaviour)
47/// - [`ReadOnlyMode::ReadOnly`] — force mutation endpoints off even if the
48///   DB can write
49/// - [`ReadOnlyMode::Writable`] — force mutation endpoints on; the DB still
50///   has the final say at query time
51pub async fn router_with(
52    pool: PgPool,
53    cache_ttl: Duration,
54    callback_hmac_secret: Option<[u8; 32]>,
55    read_only_mode: ReadOnlyMode,
56) -> Result<Router, sqlx::Error> {
57    let read_only = read_only_mode.resolve(&pool).await?;
58    let state = AppState::new(pool, read_only, cache_ttl, callback_hmac_secret);
59    let api = Router::new()
60        // Jobs
61        .route("/jobs", get(handlers::jobs::list_jobs))
62        .route("/jobs/{id}", get(handlers::jobs::get_job))
63        .route("/jobs/{id}/retry", post(handlers::jobs::retry_job))
64        .route("/jobs/{id}/cancel", post(handlers::jobs::cancel_job))
65        .route("/jobs/bulk-retry", post(handlers::jobs::bulk_retry))
66        .route("/jobs/bulk-cancel", post(handlers::jobs::bulk_cancel))
67        // Queues
68        .route("/queues", get(handlers::queues::list_queues))
69        .route(
70            "/queues/runtime",
71            get(handlers::runtime::list_queue_runtime),
72        )
73        .route("/queues/{queue}", get(handlers::queues::get_queue))
74        .route("/queues/{queue}/pause", post(handlers::queues::pause_queue))
75        .route(
76            "/queues/{queue}/resume",
77            post(handlers::queues::resume_queue),
78        )
79        .route("/queues/{queue}/drain", post(handlers::queues::drain_queue))
80        // Kinds
81        .route("/kinds", get(handlers::kinds::list_kinds))
82        // Cron
83        .route("/cron", get(handlers::cron::list_cron_jobs))
84        .route(
85            "/cron/{name}/trigger",
86            post(handlers::cron::trigger_cron_job),
87        )
88        // Stats
89        .route("/stats", get(handlers::stats::get_stats))
90        .route("/stats/timeseries", get(handlers::stats::get_timeseries))
91        .route("/stats/kinds", get(handlers::stats::get_distinct_kinds))
92        .route("/stats/queues", get(handlers::stats::get_distinct_queues))
93        .route("/capabilities", get(handlers::stats::get_capabilities))
94        // Runtime
95        .route("/runtime", get(handlers::runtime::get_runtime))
96        .route("/storage", get(handlers::runtime::get_storage))
97        // Callbacks (for HTTP workers and external systems)
98        .route(
99            "/callbacks/{callback_id}/complete",
100            post(handlers::callbacks::complete_callback),
101        )
102        .route(
103            "/callbacks/{callback_id}/fail",
104            post(handlers::callbacks::fail_callback),
105        )
106        .route(
107            "/callbacks/{callback_id}/heartbeat",
108            post(handlers::callbacks::heartbeat_callback),
109        );
110
111    Ok(Router::new()
112        .nest("/api", api)
113        .fallback(static_handler)
114        .layer(CorsLayer::permissive())
115        .with_state(state))
116}
117
118/// Serve embedded static files, falling back to index.html for SPA routing.
119async fn static_handler(uri: axum::http::Uri) -> Response {
120    let path = uri.path().trim_start_matches('/');
121
122    // Try to serve the exact file
123    if let Some(file) = StaticAssets::get(path) {
124        let mime = mime_guess::from_path(path).first_or_octet_stream();
125        return ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response();
126    }
127
128    // SPA fallback: serve index.html for non-API routes
129    if let Some(index) = StaticAssets::get("index.html") {
130        return Html(index.data.to_vec()).into_response();
131    }
132
133    // No frontend built — serve placeholder
134    Html(PLACEHOLDER_HTML).into_response()
135}
136
137const PLACEHOLDER_HTML: &str = r#"<!DOCTYPE html>
138<html>
139<head><title>AWA</title></head>
140<body>
141  <h1>AWA Job Queue</h1>
142  <p>API available at <a href="/api/stats">/api/stats</a></p>
143  <p>Frontend not built. Run <code>cd awa-ui/frontend && npm install && npm run build</code> to build the UI.</p>
144</body>
145</html>"#;