1use axum::{
40 Json, Router,
41 extract::{Path, Query, State},
42 http::StatusCode,
43 response::{IntoResponse, Response},
44 routing::{get, post},
45};
46use serde::{Deserialize, Serialize};
47use std::sync::Arc;
48use uuid::Uuid;
49use webhooksmith::WebhookEngine;
50
51#[derive(Clone)]
54struct AdminState {
55 engine: Arc<WebhookEngine>,
56}
57
58#[derive(Deserialize)]
61struct Pagination {
62 #[serde(default = "default_limit")]
63 limit: i64,
64 #[serde(default)]
65 offset: i64,
66}
67
68fn default_limit() -> i64 { 50 }
69
70#[derive(Serialize)]
73struct ErrorBody {
74 error: String,
75}
76
77fn err_json(msg: impl Into<String>) -> (StatusCode, Json<ErrorBody>) {
78 (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorBody { error: msg.into() }))
79}
80
81fn not_found(msg: impl Into<String>) -> (StatusCode, Json<ErrorBody>) {
82 (StatusCode::NOT_FOUND, Json(ErrorBody { error: msg.into() }))
83}
84
85async fn get_stats(State(s): State<AdminState>) -> impl IntoResponse {
88 match s.engine.queue_stats().await {
89 Ok(stats) => Json(stats).into_response(),
90 Err(e) => err_json(e.to_string()).into_response(),
91 }
92}
93
94async fn get_endpoints(
95 State(s): State<AdminState>,
96 Query(page): Query<Pagination>,
97) -> impl IntoResponse {
98 match s.engine.list_endpoints_paged(page.limit.clamp(1, 200), page.offset.max(0)).await {
99 Ok(eps) => Json(eps).into_response(),
100 Err(e) => err_json(e.to_string()).into_response(),
101 }
102}
103
104async fn get_dlq(
105 State(s): State<AdminState>,
106 Path(endpoint_id): Path<Uuid>,
107 Query(page): Query<Pagination>,
108) -> impl IntoResponse {
109 match s.engine.endpoint(endpoint_id).await {
111 Ok(None) => return not_found(format!("endpoint {endpoint_id} not found")).into_response(),
112 Err(e) => return err_json(e.to_string()).into_response(),
113 Ok(Some(_)) => {}
114 }
115 match s.engine.dead_events_paged(endpoint_id, page.limit.clamp(1, 200), page.offset.max(0)).await {
116 Ok(events) => Json(events).into_response(),
117 Err(e) => err_json(e.to_string()).into_response(),
118 }
119}
120
121#[derive(Serialize)]
122struct RetryAllResponse {
123 retried: u64,
124}
125
126async fn retry_all_dlq(
127 State(s): State<AdminState>,
128 Path(endpoint_id): Path<Uuid>,
129) -> impl IntoResponse {
130 match s.engine.endpoint(endpoint_id).await {
133 Ok(None) => return not_found(format!("endpoint {endpoint_id} not found")).into_response(),
134 Err(e) => return err_json(e.to_string()).into_response(),
135 Ok(Some(_)) => {}
136 }
137 match s.engine.retry_all_dead(endpoint_id).await {
138 Ok(n) => Json(RetryAllResponse { retried: n }).into_response(),
139 Err(e) => err_json(e.to_string()).into_response(),
140 }
141}
142
143fn gauge(out: &mut String, name: &str, labels: &str, value: i64) {
147 out.push_str(&format!("{name}{{{labels}}} {value}\n"));
148}
149
150async fn get_metrics(State(s): State<AdminState>) -> Response {
151 let (stats_res, endpoints_res) = tokio::join!(
153 s.engine.queue_stats(),
154 s.engine.list_endpoints(),
155 );
156
157 let (stats, endpoints) = match (stats_res, endpoints_res) {
158 (Ok(st), Ok(ep)) => (st, ep),
159 (Err(e), _) | (_, Err(e)) => {
160 return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
161 }
162 };
163
164 let mut body = String::with_capacity(512);
165
166 body.push_str("# HELP webhooksmith_events Current number of webhook events by status.\n");
168 body.push_str("# TYPE webhooksmith_events gauge\n");
169 gauge(&mut body, "webhooksmith_events", r#"status="pending""#, stats.pending);
170 gauge(&mut body, "webhooksmith_events", r#"status="delivering""#, stats.delivering);
171 gauge(&mut body, "webhooksmith_events", r#"status="failed""#, stats.failed);
172 gauge(&mut body, "webhooksmith_events", r#"status="dead""#, stats.dead);
173 gauge(&mut body, "webhooksmith_events", r#"status="delivered""#, stats.delivered);
174
175 let enabled = endpoints.iter().filter(|e| e.enabled && e.circuit_open_until.is_none()).count() as i64;
177 let disabled = endpoints.iter().filter(|e| !e.enabled).count() as i64;
178 let circuit_open = endpoints.iter().filter(|e| {
179 e.circuit_open_until.map(|t| t > chrono::Utc::now()).unwrap_or(false)
180 }).count() as i64;
181
182 body.push_str("\n# HELP webhooksmith_endpoints Current number of registered endpoints by state.\n");
183 body.push_str("# TYPE webhooksmith_endpoints gauge\n");
184 gauge(&mut body, "webhooksmith_endpoints", r#"state="enabled""#, enabled);
185 gauge(&mut body, "webhooksmith_endpoints", r#"state="disabled""#, disabled);
186 gauge(&mut body, "webhooksmith_endpoints", r#"state="circuit_open""#, circuit_open);
187
188 (
189 StatusCode::OK,
190 [(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")],
191 body,
192 )
193 .into_response()
194}
195
196pub fn admin(engine: Arc<WebhookEngine>) -> Router {
206 let state = AdminState { engine };
207 Router::new()
208 .route("/stats", get(get_stats))
209 .route("/endpoints", get(get_endpoints))
210 .route("/dlq/:endpoint_id", get(get_dlq))
211 .route("/dlq/:endpoint_id/retry-all", post(retry_all_dlq))
212 .route("/metrics", get(get_metrics))
213 .with_state(state)
214}