1use axum::{extract::State, http::HeaderMap, Json};
10use chrono::Datelike;
11use serde::Serialize;
12use uuid::Uuid;
13
14use crate::{
15 error::{ApiError, ApiResult},
16 middleware::{resolve_org_context, AuthUser},
17 AppState,
18};
19
20#[derive(Debug, Serialize)]
21pub struct CloudDashboardResponse {
22 pub server_info: ServerInfo,
23 pub system_info: SystemInfo,
24 pub metrics: Metrics,
25 pub servers: Vec<ServerStatus>,
26 pub recent_logs: Vec<serde_json::Value>,
27 pub system: System,
28 pub cloud_metrics: CloudMetrics,
29}
30
31#[derive(Debug, Serialize)]
32pub struct ServerInfo {
33 pub version: String,
34 pub build_time: String,
35 pub git_sha: String,
36 pub api_enabled: bool,
37 pub admin_port: u16,
38}
39
40#[derive(Debug, Serialize)]
41pub struct SystemInfo {
42 pub os: String,
43 pub arch: String,
44 pub uptime: u64,
45 pub memory_usage: u64,
46}
47
48#[derive(Debug, Serialize)]
49pub struct Metrics {
50 pub total_requests: u64,
51 pub active_requests: u64,
52 pub average_response_time: f64,
53 pub error_rate: f64,
54}
55
56#[derive(Debug, Serialize)]
57pub struct System {
58 pub version: String,
59 pub uptime_seconds: u64,
60 pub memory_usage_mb: f64,
61 pub cpu_usage_percent: f64,
62 pub active_threads: u64,
63 pub total_routes: i64,
64 pub total_fixtures: i64,
65}
66
67#[derive(Debug, Serialize)]
70pub struct ServerStatus {
71 pub server_type: String,
72 pub address: Option<String>,
73 pub running: bool,
74 pub start_time: Option<chrono::DateTime<chrono::Utc>>,
75 pub uptime_seconds: Option<u64>,
76 pub active_connections: u64,
77 pub total_requests: u64,
78}
79
80#[derive(Debug, Serialize, Default)]
84pub struct CloudMetrics {
85 pub active_deployments: i64,
86 pub total_deployments: i64,
87 pub workspaces: i64,
88 pub services: i64,
89 pub fixtures: i64,
90 pub federations: i64,
91 pub requests_2xx: i64,
92 pub requests_4xx: i64,
93 pub requests_5xx: i64,
94 pub egress_bytes: i64,
95 pub period_start: Option<chrono::NaiveDate>,
96}
97
98#[derive(sqlx::FromRow)]
99struct AggregatedMetrics {
100 total_requests: Option<i64>,
101 requests_2xx: Option<i64>,
102 requests_4xx: Option<i64>,
103 requests_5xx: Option<i64>,
104 egress_bytes: Option<i64>,
105 weighted_avg_response_time_ms: Option<f64>,
106}
107
108#[derive(sqlx::FromRow)]
109struct ActiveDeployment {
110 name: String,
111 deployment_url: Option<String>,
112 region: String,
113 created_at: chrono::DateTime<chrono::Utc>,
114 requests: Option<i64>,
115}
116
117pub async fn get_dashboard(
119 State(state): State<AppState>,
120 AuthUser(user_id): AuthUser,
121 headers: HeaderMap,
122) -> ApiResult<Json<CloudDashboardResponse>> {
123 let pool = state.db.pool();
124
125 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
126 .await
127 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
128
129 let org_id = org_ctx.org_id;
130
131 let workspace_count = count_table(pool, "workspaces", org_id).await;
132 let service_count = count_table(pool, "services", org_id).await;
133 let fixture_count = count_table(pool, "fixtures", org_id).await;
134 let federation_count = count_table(pool, "federations", org_id).await;
135 let total_deployments = count_table(pool, "hosted_mocks", org_id).await;
136 let active_deployments = count_active_deployments(pool, org_id).await;
137
138 let aggregated = aggregate_deployment_metrics(pool, org_id).await;
139 let total_requests = aggregated.total_requests.unwrap_or(0).max(0) as u64;
140 let requests_2xx = aggregated.requests_2xx.unwrap_or(0);
141 let requests_4xx = aggregated.requests_4xx.unwrap_or(0);
142 let requests_5xx = aggregated.requests_5xx.unwrap_or(0);
143 let error_count = (requests_4xx + requests_5xx).max(0) as f64;
144 let error_rate = if total_requests > 0 {
145 (error_count / total_requests as f64) * 100.0
146 } else {
147 0.0
148 };
149
150 let now = chrono::Utc::now().date_naive();
151 let period_start = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1);
152
153 let servers = list_active_deployment_servers(pool, org_id).await;
154
155 Ok(Json(CloudDashboardResponse {
156 server_info: ServerInfo {
157 version: "cloud".to_string(),
158 build_time: String::new(),
159 git_sha: String::new(),
160 api_enabled: true,
161 admin_port: 0,
162 },
163 system_info: SystemInfo {
164 os: "cloud".to_string(),
165 arch: "cloud".to_string(),
166 uptime: 0,
167 memory_usage: 0,
168 },
169 metrics: Metrics {
170 total_requests,
171 active_requests: 0,
172 average_response_time: aggregated.weighted_avg_response_time_ms.unwrap_or(0.0),
173 error_rate,
174 },
175 servers,
176 recent_logs: vec![],
177 system: System {
178 version: "cloud".to_string(),
179 uptime_seconds: 0,
180 memory_usage_mb: 0.0,
181 cpu_usage_percent: 0.0,
182 active_threads: 0,
183 total_routes: service_count,
184 total_fixtures: fixture_count,
185 },
186 cloud_metrics: CloudMetrics {
187 active_deployments,
188 total_deployments,
189 workspaces: workspace_count,
190 services: service_count,
191 fixtures: fixture_count,
192 federations: federation_count,
193 requests_2xx,
194 requests_4xx,
195 requests_5xx,
196 egress_bytes: aggregated.egress_bytes.unwrap_or(0),
197 period_start,
198 },
199 }))
200}
201
202pub async fn get_health(
204 State(state): State<AppState>,
205 AuthUser(user_id): AuthUser,
206 headers: HeaderMap,
207) -> ApiResult<Json<serde_json::Value>> {
208 let _org_ctx = resolve_org_context(&state, user_id, &headers, None)
209 .await
210 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
211
212 Ok(Json(serde_json::json!({
213 "status": "healthy",
214 "services": {},
215 "last_check": chrono::Utc::now().to_rfc3339(),
216 "issues": []
217 })))
218}
219
220pub async fn get_logs(
222 State(state): State<AppState>,
223 AuthUser(user_id): AuthUser,
224 headers: HeaderMap,
225) -> ApiResult<Json<Vec<serde_json::Value>>> {
226 let pool = state.db.pool();
227
228 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
229 .await
230 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
231
232 let logs: Vec<serde_json::Value> = sqlx::query_scalar(
233 r#"
234 SELECT json_build_object(
235 'id', id::text,
236 'timestamp', created_at,
237 'event_type', event_type::text,
238 'description', description,
239 'user_id', user_id::text,
240 'ip_address', ip_address
241 )
242 FROM audit_logs
243 WHERE org_id = $1
244 ORDER BY created_at DESC
245 LIMIT 50
246 "#,
247 )
248 .bind(org_ctx.org_id)
249 .fetch_all(pool)
250 .await
251 .unwrap_or_default();
252
253 Ok(Json(logs))
254}
255
256async fn count_table(pool: &sqlx::PgPool, table: &str, org_id: Uuid) -> i64 {
257 let query = format!("SELECT COUNT(*) FROM {} WHERE org_id = $1", table);
258 sqlx::query_scalar::<_, i64>(&query)
259 .bind(org_id)
260 .fetch_one(pool)
261 .await
262 .unwrap_or(0)
263}
264
265async fn count_active_deployments(pool: &sqlx::PgPool, org_id: Uuid) -> i64 {
266 sqlx::query_scalar::<_, i64>(
267 "SELECT COUNT(*) FROM hosted_mocks WHERE org_id = $1 AND status = 'active' AND deleted_at IS NULL",
268 )
269 .bind(org_id)
270 .fetch_one(pool)
271 .await
272 .unwrap_or(0)
273}
274
275async fn aggregate_deployment_metrics(pool: &sqlx::PgPool, org_id: Uuid) -> AggregatedMetrics {
280 sqlx::query_as::<_, AggregatedMetrics>(
281 r#"
282 SELECT
283 COALESCE(SUM(dm.requests), 0)::BIGINT AS total_requests,
284 COALESCE(SUM(dm.requests_2xx), 0)::BIGINT AS requests_2xx,
285 COALESCE(SUM(dm.requests_4xx), 0)::BIGINT AS requests_4xx,
286 COALESCE(SUM(dm.requests_5xx), 0)::BIGINT AS requests_5xx,
287 COALESCE(SUM(dm.egress_bytes), 0)::BIGINT AS egress_bytes,
288 CASE WHEN COALESCE(SUM(dm.requests), 0) > 0
289 THEN (SUM(dm.requests * dm.avg_response_time_ms)::FLOAT8
290 / NULLIF(SUM(dm.requests), 0)::FLOAT8)
291 ELSE 0.0
292 END AS weighted_avg_response_time_ms
293 FROM deployment_metrics dm
294 JOIN hosted_mocks hm ON hm.id = dm.hosted_mock_id
295 WHERE hm.org_id = $1 AND hm.deleted_at IS NULL
296 "#,
297 )
298 .bind(org_id)
299 .fetch_optional(pool)
300 .await
301 .ok()
302 .flatten()
303 .unwrap_or(AggregatedMetrics {
304 total_requests: Some(0),
305 requests_2xx: Some(0),
306 requests_4xx: Some(0),
307 requests_5xx: Some(0),
308 egress_bytes: Some(0),
309 weighted_avg_response_time_ms: Some(0.0),
310 })
311}
312
313async fn list_active_deployment_servers(pool: &sqlx::PgPool, org_id: Uuid) -> Vec<ServerStatus> {
317 let rows = sqlx::query_as::<_, ActiveDeployment>(
318 r#"
319 SELECT
320 hm.name,
321 hm.deployment_url,
322 hm.region,
323 hm.created_at,
324 (
325 SELECT dm.requests
326 FROM deployment_metrics dm
327 WHERE dm.hosted_mock_id = hm.id
328 ORDER BY dm.period_start DESC
329 LIMIT 1
330 ) AS requests
331 FROM hosted_mocks hm
332 WHERE hm.org_id = $1 AND hm.status = 'active' AND hm.deleted_at IS NULL
333 ORDER BY hm.created_at DESC
334 LIMIT 25
335 "#,
336 )
337 .bind(org_id)
338 .fetch_all(pool)
339 .await
340 .unwrap_or_default();
341
342 let now = chrono::Utc::now();
343 rows.into_iter()
344 .map(|r| {
345 let uptime = (now - r.created_at).num_seconds().max(0) as u64;
346 ServerStatus {
347 server_type: format!("{} ({})", r.name, r.region),
348 address: r.deployment_url,
349 running: true,
350 start_time: Some(r.created_at),
351 uptime_seconds: Some(uptime),
352 active_connections: 0,
353 total_requests: r.requests.unwrap_or(0).max(0) as u64,
354 }
355 })
356 .collect()
357}