Skip to main content

mockforge_registry_server/handlers/
cloud_dashboard.rs

1//! Cloud dashboard handler — provides dashboard metrics for cloud-hosted users
2//!
3//! Returns data in the same shape as the local /__mockforge/dashboard endpoint
4//! so the existing DashboardPage UI components work in cloud mode. Cloud-only
5//! aggregates (per-status counts, egress, deployment list, audit activity) are
6//! exposed under the extra `cloud_metrics` field — DashboardResponseSchema uses
7//! `.passthrough()` so the UI sees them when running against this server.
8
9use 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/// Shape that matches the UI's `ServerStatus` type so ServerTable renders
68/// hosted-mock deployments as if they were running mock servers.
69#[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/// Cloud-only aggregates that don't fit the local-mode dashboard shape.
81/// Surfaced under `cloud_metrics` so the UI can replace local-only tiles
82/// (CPU/memory/threads/uptime) with cloud-relevant ones.
83#[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
117/// Get cloud dashboard data with real counts from the database
118pub 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
202/// Get cloud health status
203pub 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
220/// Get cloud request logs (returns recent audit events as a proxy)
221pub 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
275/// SUM deployment_metrics across all of an org's hosted mocks for the current
276/// period. Average response time is request-weighted so a busy mock outweighs
277/// a quiet one. Unavailable until #232 lands the in-container log shipper, so
278/// expect zeros for orgs without Fly Managed Prometheus configured.
279async 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
313/// Map active hosted-mock deployments into the `ServerStatus` shape the UI's
314/// ServerTable expects. Each deployment becomes one "server" row with its
315/// public URL as the address and the current period's request count.
316async 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}