From c76d6b639609f6c2973786b8b3022674e661252e Mon Sep 17 00:00:00 2001 From: elky Date: Fri, 26 Jun 2026 01:32:45 +0800 Subject: [PATCH] Add admin operations dashboard and usage state fixes --- .../observability/usage/detail_routes.rs | 16 + .../observability/usage/summary_routes.rs | 60 +- .../src/tests/control/admin/usage.rs | 76 ++ .../usage/components/RequestDetailDrawer.vue | 9 +- frontend/src/layouts/MainLayout.vue | 2 +- frontend/src/mocks/handler.ts | 370 +++++- frontend/src/router/index.ts | 2 +- .../views/admin/AdminOperationsDashboard.vue | 1129 +++++++++++++++++ 8 files changed, 1656 insertions(+), 8 deletions(-) create mode 100644 frontend/src/views/admin/AdminOperationsDashboard.vue diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs index bfa5d7c52..598cd55cf 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs @@ -7,6 +7,9 @@ use super::replay::{ admin_usage_resolve_request_capture_body_for_item, build_admin_usage_curl_response, build_admin_usage_detail_payload, build_admin_usage_replay_response, }; +use super::summary_routes::{ + admin_usage_terminal_candidate_state_override, apply_admin_usage_state_override, +}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_bool}; use crate::GatewayError; @@ -233,6 +236,19 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response( let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names); let mut detail_item = item.clone(); + if matches!(detail_item.status.as_str(), "pending" | "streaming") + && state.has_request_candidate_data_reader() + { + let candidates = state + .app() + .read_request_candidates_by_request_id(&detail_item.request_id) + .await?; + if let Some(override_payload) = + admin_usage_terminal_candidate_state_override(&candidates) + { + apply_admin_usage_state_override(&mut detail_item, &override_payload); + } + } let mut body_load_errors = serde_json::Map::new(); let request_body = if include_bodies { let (request_body, provider_request_body, response_body, client_response_body) = tokio::join!( diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index 559b8a36a..315ef5872 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -301,7 +301,7 @@ fn admin_usage_unix_millis_to_rfc3339(unix_ms: u64) -> Option { .map(|timestamp| timestamp.to_rfc3339()) } -fn admin_usage_terminal_candidate_state_override( +pub(super) fn admin_usage_terminal_candidate_state_override( candidates: &[StoredRequestCandidate], ) -> Option { let candidate = admin_usage_current_candidate(candidates)?; @@ -343,6 +343,49 @@ fn admin_usage_terminal_candidate_state_override( Some(payload) } +pub(super) fn apply_admin_usage_state_override( + item: &mut StoredRequestUsageAudit, + override_payload: &serde_json::Value, +) { + if !matches!(item.status.as_str(), "pending" | "streaming") { + return; + } + + let Some(object) = override_payload.as_object() else { + return; + }; + + if let Some(status) = object + .get("status") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| matches!(*value, "completed" | "failed" | "cancelled")) + { + item.status = status.to_string(); + } + if let Some(status_code) = object + .get("status_code") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + { + item.status_code = Some(status_code); + } + if let Some(response_time_ms) = object + .get("response_time_ms") + .and_then(serde_json::Value::as_u64) + { + item.response_time_ms = Some(response_time_ms); + } + if let Some(error_message) = object + .get("error_message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + item.error_message = Some(error_message.to_string()); + } +} + fn admin_usage_matches_attempt_status( item: &StoredRequestUsageAudit, status: &str, @@ -901,6 +944,21 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( } }; + let active_candidate_state = + resolve_admin_usage_active_candidate_state(state, &usage).await?; + let usage = usage + .into_iter() + .map(|mut item| { + if let Some(override_payload) = active_candidate_state + .state_overrides_by_request_id + .get(&item.request_id) + { + apply_admin_usage_state_override(&mut item, override_payload); + } + item + }) + .collect::>(); + let user_ids: Vec = usage .iter() .filter_map(|item| item.user_id.clone()) diff --git a/apps/aether-gateway/src/tests/control/admin/usage.rs b/apps/aether-gateway/src/tests/control/admin/usage.rs index ff0bfef78..0f768049e 100644 --- a/apps/aether-gateway/src/tests/control/admin/usage.rs +++ b/apps/aether-gateway/src/tests/control/admin/usage.rs @@ -1170,6 +1170,82 @@ async fn gateway_handles_admin_usage_active_ids_for_terminal_updates() { upstream_handle.abort(); } +#[tokio::test] +async fn gateway_derives_admin_usage_records_and_detail_status_from_terminal_candidate() { + let (_records_upstream_url, records_upstream_hits, records_upstream_handle) = + start_usage_upstream("/api/admin/usage/records").await; + + let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![sample_usage_row( + "usage-stale-stream", + "req-stale-stream", + Some("user-1"), + Some("key-1"), + Some("primary"), + "OpenAI", + "gpt-5", + "streaming", + 20, + 5, + 0.2, + 0.24, + DAY_1_UNIX_SECS, + )])); + let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![ + sample_request_candidate( + "cand-stale-stream-success", + "req-stale-stream", + 0, + 0, + RequestCandidateStatus::Success, + ), + ])); + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_request_candidate_and_usage_repository_for_tests( + request_candidate_repository, + usage_repository, + ), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let records_response = admin_request(reqwest::Client::new().get(format!( + "{gateway_url}/api/admin/usage/records?start_date=2024-03-21&end_date=2024-03-21&tz_offset_minutes=0&limit=10&offset=0" + ))) + .send() + .await + .expect("records request should succeed"); + + assert_eq!(records_response.status(), StatusCode::OK); + let records_payload: serde_json::Value = records_response + .json() + .await + .expect("records json should parse"); + assert_eq!(records_payload["records"][0]["id"], "usage-stale-stream"); + assert_eq!(records_payload["records"][0]["status"], "completed"); + + let detail_response = admin_request( + reqwest::Client::new().get(format!("{gateway_url}/api/admin/usage/usage-stale-stream")), + ) + .send() + .await + .expect("detail request should succeed"); + + assert_eq!(detail_response.status(), StatusCode::OK); + let detail_payload: serde_json::Value = detail_response + .json() + .await + .expect("detail json should parse"); + assert_eq!(detail_payload["id"], "usage-stale-stream"); + assert_eq!(detail_payload["status"], "completed"); + assert_eq!(*records_upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + records_upstream_handle.abort(); +} + #[tokio::test] async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principal() { let (_upstream_url, upstream_hits, upstream_handle) = diff --git a/frontend/src/features/usage/components/RequestDetailDrawer.vue b/frontend/src/features/usage/components/RequestDetailDrawer.vue index c9cbdd0e2..cfc099792 100644 --- a/frontend/src/features/usage/components/RequestDetailDrawer.vue +++ b/frontend/src/features/usage/components/RequestDetailDrawer.vue @@ -2432,9 +2432,12 @@ async function loadDetail(id: string, silent = false) { timelineRef.value?.refresh() } - // 已完成请求需要停止自动刷新;进行中的请求只在用户手动开启后才保持刷新 - if (props.isOpen && isRequestCompleted()) { - stopAutoRefresh() + if (props.isOpen) { + if (isRequestCompleted()) { + stopAutoRefresh() + } else if (!silent) { + startAutoRefresh() + } } } catch (err) { if (requestId !== loadDetailRequestId) return diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 06ae342d4..ec293874d 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -1180,7 +1180,7 @@ const navigation = computed(() => { { title: '概览', items: [ - { name: '仪表盘', href: '/admin/dashboard', icon: Home }, + { name: '运维总览', href: '/admin/dashboard', icon: Home }, { name: '健康监控', href: '/admin/health-monitor', icon: Activity }, { name: '用户统计', href: '/admin/user-stats', icon: BarChart3 }, { name: '成本分析', href: '/admin/cost-analysis', icon: Gauge }, diff --git a/frontend/src/mocks/handler.ts b/frontend/src/mocks/handler.ts index 7437b295d..1cb50c4d6 100644 --- a/frontend/src/mocks/handler.ts +++ b/frontend/src/mocks/handler.ts @@ -4100,6 +4100,98 @@ mockHandlers['GET /api/admin/stats/performance/percentiles'] = async () => { ]) } +mockHandlers['GET /api/admin/stats/performance/providers'] = async () => { + await delay() + requireAdmin() + return createMockResponse({ + summary: { + request_count: 18420, + success_rate: 97.36, + avg_output_tps: 78.4, + avg_first_byte_time_ms: 318, + avg_response_time_ms: 1640, + p90_response_time_ms: 2860, + p99_response_time_ms: 9120, + p90_first_byte_time_ms: 690, + p99_first_byte_time_ms: 2210, + tps_sample_count: 17120, + response_time_sample_count: 18200, + first_byte_sample_count: 16840, + slow_request_count: 214 + }, + providers: [ + { + provider_id: 'provider-openai', + provider: 'OpenAI', + request_count: 8120, + success_count: 7930, + error_count: 190, + success_rate: 97.66, + output_tokens: 1480000, + avg_output_tps: 83.2, + avg_first_byte_time_ms: 286, + avg_response_time_ms: 1420, + p90_response_time_ms: 2440, + p99_response_time_ms: 7520, + p90_first_byte_time_ms: 620, + p99_first_byte_time_ms: 1840, + tps_sample_count: 7890, + response_time_sample_count: 8050, + first_byte_sample_count: 7760, + slow_request_count: 71 + }, + { + provider_id: 'provider-anthropic', + provider: 'Anthropic', + request_count: 6230, + success_count: 6044, + error_count: 186, + success_rate: 97.01, + output_tokens: 1120000, + avg_output_tps: 62.7, + avg_first_byte_time_ms: 410, + avg_response_time_ms: 1880, + p90_response_time_ms: 3180, + p99_response_time_ms: 10120, + p90_first_byte_time_ms: 820, + p99_first_byte_time_ms: 2680, + tps_sample_count: 5900, + response_time_sample_count: 6150, + first_byte_sample_count: 5810, + slow_request_count: 109 + }, + { + provider_id: 'provider-gemini', + provider: 'Gemini', + request_count: 4070, + success_count: 3960, + error_count: 110, + success_rate: 97.30, + output_tokens: 780000, + avg_output_tps: 91.1, + avg_first_byte_time_ms: 240, + avg_response_time_ms: 1210, + p90_response_time_ms: 2190, + p99_response_time_ms: 6420, + p90_first_byte_time_ms: 520, + p99_first_byte_time_ms: 1510, + tps_sample_count: 3930, + response_time_sample_count: 4000, + first_byte_sample_count: 3820, + slow_request_count: 34 + } + ], + timeline: [ + { date: '2026-02-01', provider_id: 'provider-openai', provider: 'OpenAI', request_count: 4020, output_tokens: 720000, avg_output_tps: 81.4, avg_first_byte_time_ms: 294, avg_response_time_ms: 1450, success_rate: 97.4, slow_request_count: 38 }, + { date: '2026-02-02', provider_id: 'provider-openai', provider: 'OpenAI', request_count: 4100, output_tokens: 760000, avg_output_tps: 85.1, avg_first_byte_time_ms: 278, avg_response_time_ms: 1390, success_rate: 97.9, slow_request_count: 33 }, + { date: '2026-02-01', provider_id: 'provider-anthropic', provider: 'Anthropic', request_count: 3020, output_tokens: 540000, avg_output_tps: 60.8, avg_first_byte_time_ms: 430, avg_response_time_ms: 1940, success_rate: 96.7, slow_request_count: 58 }, + { date: '2026-02-02', provider_id: 'provider-anthropic', provider: 'Anthropic', request_count: 3210, output_tokens: 580000, avg_output_tps: 64.2, avg_first_byte_time_ms: 392, avg_response_time_ms: 1810, success_rate: 97.3, slow_request_count: 51 }, + { date: '2026-02-01', provider_id: 'provider-gemini', provider: 'Gemini', request_count: 2020, output_tokens: 382000, avg_output_tps: 88.6, avg_first_byte_time_ms: 252, avg_response_time_ms: 1260, success_rate: 97.0, slow_request_count: 18 }, + { date: '2026-02-02', provider_id: 'provider-gemini', provider: 'Gemini', request_count: 2050, output_tokens: 398000, avg_output_tps: 93.6, avg_first_byte_time_ms: 228, avg_response_time_ms: 1160, success_rate: 97.6, slow_request_count: 16 } + ] + }) +} + mockHandlers['GET /api/admin/stats/errors/distribution'] = async () => { await delay() requireAdmin() @@ -4249,6 +4341,241 @@ mockHandlers['GET /api/admin/monitoring/resilience/circuit-history'] = async () }) } +mockHandlers['GET /api/admin/monitoring/cache/stats'] = async () => { + await delay() + requireAdmin() + return createMockResponse({ + status: 'ok', + data: { + scheduler: 'adaptive-cache-affinity', + total_affinities: 1428, + cache_hit_rate: 0.842, + provider_switches: 38, + key_switches: 74, + cache_hits: 93842, + cache_misses: 17610, + scheduler_metrics: { + cache_hits: 93842, + cache_misses: 17610, + cache_hit_rate: 0.842, + total_batches: 0, + last_batch_size: 0, + total_candidates: 0, + last_candidate_count: 0, + concurrency_denied: 0, + avg_candidates_per_batch: 0, + scheduling_mode: 'adaptive', + provider_priority_mode: 'weighted' + }, + affinity_stats: { + storage_type: 'redis', + total_affinities: 1428, + active_affinities: 1410, + cache_hits: 93842, + cache_misses: 17610, + cache_hit_rate: 0.842, + cache_invalidations: 42, + provider_switches: 38, + key_switches: 74, + config: { + default_ttl: 300 + } + } + } + }) +} + +mockHandlers['GET /api/admin/monitoring/cache/redis-keys'] = async () => { + await delay() + requireAdmin() + return createMockResponse({ + status: 'ok', + data: { + available: true, + categories: [ + { key: 'cache_affinity', name: '缓存亲和性', pattern: 'cache_affinity:*', description: '请求路由亲和性缓存', count: 1428 }, + { key: 'concurrency_lock', name: '并发锁', pattern: 'concurrency:*', description: '请求并发控制锁', count: 186 }, + { key: 'apikey', name: 'API Key', pattern: 'apikey:*', description: 'API Key 认证缓存', count: 263 }, + { key: 'health', name: '健康检查', pattern: 'health:*', description: '端点健康状态缓存', count: 154 }, + { key: 'models_list', name: '模型列表', pattern: 'models:list:*', description: '/v1/models 端点模型列表缓存', count: 38 }, + { key: 'provider_balance', name: 'Provider 余额', pattern: 'provider_ops:balance:*', description: 'Provider 余额查询缓存', count: 24 } + ], + total_keys: 2093 + } + }) +} + +mockHandlers['GET /api/admin/proxy-nodes'] = async () => { + await delay() + requireAdmin() + const now = new Date().toISOString() + return createMockResponse({ + items: [ + { + id: 'proxy-node-1', + name: 'edge-shanghai-1', + ip: '10.8.0.12', + port: 8443, + region: 'cn-east', + status: 'online', + is_manual: false, + tunnel_mode: true, + tunnel_connected: true, + tunnel_connected_at: now, + hardware_info: { + cpu_cores: 8, + total_memory_mb: 16384, + os_info: 'linux' + }, + estimated_max_concurrency: 620, + remote_config: null, + config_version: 12, + registered_by: 'agent', + last_heartbeat_at: now, + heartbeat_interval: 15, + active_connections: 82, + total_requests: 43820, + avg_latency_ms: 42, + failed_requests: 34, + dns_failures: 2, + stream_errors: 7, + proxy_metadata: { + resource_usage: { + system_cpu_usage_percent: 41.8, + process_cpu_usage_percent: 18.4, + memory_total_bytes: 17179869184, + memory_used_bytes: 9878424780, + memory_used_percent: 57.5, + process_memory_bytes: 438304768, + process_memory_percent: 2.6, + process_uptime_secs: 86400 + } + }, + created_at: now, + updated_at: now + }, + { + id: 'proxy-node-2', + name: 'edge-us-west-1', + ip: '10.8.1.18', + port: 8443, + region: 'us-west', + status: 'online', + is_manual: false, + tunnel_mode: true, + tunnel_connected: true, + tunnel_connected_at: now, + hardware_info: { + cpu_cores: 16, + total_memory_mb: 32768, + os_info: 'linux' + }, + estimated_max_concurrency: 980, + remote_config: null, + config_version: 9, + registered_by: 'agent', + last_heartbeat_at: now, + heartbeat_interval: 15, + active_connections: 94, + total_requests: 58340, + avg_latency_ms: 58, + failed_requests: 52, + dns_failures: 1, + stream_errors: 9, + proxy_metadata: { + resource_usage: { + system_cpu_usage_percent: 55.2, + process_cpu_usage_percent: 22.6, + memory_total_bytes: 34359738368, + memory_used_bytes: 22333829939, + memory_used_percent: 65.0, + process_memory_bytes: 612368384, + process_memory_percent: 1.8, + process_uptime_secs: 172800 + } + }, + created_at: now, + updated_at: now + } + ], + total: 2, + skip: 0, + limit: 200, + rollout: null + }) +} + +mockHandlers['GET /api/admin/proxy-nodes/metrics/fleet'] = async () => { + await delay() + requireAdmin() + const now = Math.floor(Date.now() / 1000) + return createMockResponse({ + step: '1m', + from: now - 3600, + to: now, + items: [ + { + bucket_start_unix_secs: now - 120, + bucket_start: new Date((now - 120) * 1000).toISOString(), + samples: 2, + uptime_samples: 2, + uptime_ratio: 1, + active_connections_sum: 168, + active_connections_max: 92, + active_connections_avg: 84, + heartbeat_rtt_ms_sum: 86, + heartbeat_rtt_ms_max: 52, + heartbeat_rtt_ms_avg: 43, + connect_errors_delta: 0, + disconnects_delta: 0, + error_events_delta: 1, + ws_in_bytes_delta: 4849664, + ws_out_bytes_delta: 12812288, + ws_in_frames_delta: 18320, + ws_out_frames_delta: 42110 + }, + { + bucket_start_unix_secs: now - 60, + bucket_start: new Date((now - 60) * 1000).toISOString(), + samples: 2, + uptime_samples: 2, + uptime_ratio: 1, + active_connections_sum: 176, + active_connections_max: 94, + active_connections_avg: 88, + heartbeat_rtt_ms_sum: 82, + heartbeat_rtt_ms_max: 48, + heartbeat_rtt_ms_avg: 41, + connect_errors_delta: 0, + disconnects_delta: 0, + error_events_delta: 0, + ws_in_bytes_delta: 5154816, + ws_out_bytes_delta: 13996032, + ws_in_frames_delta: 19120, + ws_out_frames_delta: 44710 + } + ], + summary: { + samples: 4, + uptime_samples: 4, + uptime_ratio: 1, + active_connections_sum: 344, + active_connections_max: 94, + active_connections_avg: 86, + heartbeat_rtt_ms_sum: 168, + heartbeat_rtt_ms_max: 52, + heartbeat_rtt_ms_avg: 42, + connect_errors_delta: 0, + disconnects_delta: 0, + error_events_delta: 1, + ws_in_bytes_delta: 10004480, + ws_out_bytes_delta: 26808320, + ws_in_frames_delta: 37440, + ws_out_frames_delta: 86820 + } + }) +} + mockHandlers['GET /_gateway/metrics'] = async () => { await delay(60) requireAdmin() @@ -4288,6 +4615,45 @@ aether_gateway_tunnel_nodes 6 # HELP aether_gateway_tunnel_active_streams Current number of active local relay streams. # TYPE aether_gateway_tunnel_active_streams gauge aether_gateway_tunnel_active_streams 164 +# HELP aether_gateway_tunnel_proxy_connections_available Current number of available proxy sockets. +# TYPE aether_gateway_tunnel_proxy_connections_available gauge +aether_gateway_tunnel_proxy_connections_available 24 +# HELP aether_gateway_tunnel_proxy_connections_closing Current number of closing proxy sockets. +# TYPE aether_gateway_tunnel_proxy_connections_closing gauge +aether_gateway_tunnel_proxy_connections_closing 1 +# HELP aether_gateway_tunnel_proxy_connections_draining Current number of draining proxy sockets. +# TYPE aether_gateway_tunnel_proxy_connections_draining gauge +aether_gateway_tunnel_proxy_connections_draining 2 +# HELP aether_gateway_tunnel_proxy_connections_soft_avoid Current number of soft-avoid proxy sockets. +# TYPE aether_gateway_tunnel_proxy_connections_soft_avoid gauge +aether_gateway_tunnel_proxy_connections_soft_avoid 3 +# HELP aether_gateway_tunnel_proxy_outbound_queue_depth_total Current outbound queue depth total. +# TYPE aether_gateway_tunnel_proxy_outbound_queue_depth_total gauge +aether_gateway_tunnel_proxy_outbound_queue_depth_total 312 +# HELP aether_gateway_tunnel_proxy_outbound_queue_depth_max Current outbound queue max depth. +# TYPE aether_gateway_tunnel_proxy_outbound_queue_depth_max gauge +aether_gateway_tunnel_proxy_outbound_queue_depth_max 38 +# HELP aether_gateway_tunnel_proxy_outbound_queue_capacity_total Current outbound queue capacity total. +# TYPE aether_gateway_tunnel_proxy_outbound_queue_capacity_total gauge +aether_gateway_tunnel_proxy_outbound_queue_capacity_total 2048 +# HELP aether_gateway_tunnel_proxy_outbound_queue_rejected_full_total Outbound queue full rejections. +# TYPE aether_gateway_tunnel_proxy_outbound_queue_rejected_full_total counter +aether_gateway_tunnel_proxy_outbound_queue_rejected_full_total 7 +# HELP aether_gateway_tunnel_proxy_outbound_queue_rejected_closed_total Outbound queue closed rejections. +# TYPE aether_gateway_tunnel_proxy_outbound_queue_rejected_closed_total counter +aether_gateway_tunnel_proxy_outbound_queue_rejected_closed_total 2 +# HELP aether_gateway_tunnel_proxy_connection_congested_total Congested proxy selections. +# TYPE aether_gateway_tunnel_proxy_connection_congested_total counter +aether_gateway_tunnel_proxy_connection_congested_total 21 +# HELP aether_gateway_tunnel_proxy_soft_avoid_selection_total Soft-avoid proxy selections. +# TYPE aether_gateway_tunnel_proxy_soft_avoid_selection_total counter +aether_gateway_tunnel_proxy_soft_avoid_selection_total 18 +# HELP aether_gateway_tunnel_proxy_selection_retry_total Proxy selection retries. +# TYPE aether_gateway_tunnel_proxy_selection_retry_total counter +aether_gateway_tunnel_proxy_selection_retry_total 11 +# HELP aether_gateway_tunnel_proxy_selection_unavailable_total Proxy selection unavailable events. +# TYPE aether_gateway_tunnel_proxy_selection_unavailable_total counter +aether_gateway_tunnel_proxy_selection_unavailable_total 4 # HELP aether_gateway_decision_remote_total Number of requests that fell back to Python decision endpoints. # TYPE aether_gateway_decision_remote_total counter aether_gateway_decision_remote_total{route_kind="chat",reason="local_decision_miss"} 4 @@ -4343,7 +4709,7 @@ mockHandlers['GET /api/admin/stats/time-series'] = async () => { await delay() requireAdmin() return createMockResponse([ - { date: '2026-02-01', total_requests: 120, input_tokens: 20000, output_tokens: 30000, total_cost: 12.3 }, - { date: '2026-02-02', total_requests: 140, input_tokens: 22000, output_tokens: 32000, total_cost: 13.8 } + { date: '2026-02-01', total_requests: 120, input_tokens: 20000, output_tokens: 30000, total_tokens: 50000, total_cost: 12.3 }, + { date: '2026-02-02', total_requests: 140, input_tokens: 22000, output_tokens: 32000, total_tokens: 54000, total_cost: 13.8 } ]) } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dc639cf1b..17c82db0c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -169,7 +169,7 @@ const routes: RouteRecordRaw[] = [ { path: 'dashboard', name: 'AdminDashboard', - component: () => importWithRetry(() => import('@/views/shared/Dashboard.vue')) + component: () => importWithRetry(() => import('@/views/admin/AdminOperationsDashboard.vue')) }, { path: 'users', diff --git a/frontend/src/views/admin/AdminOperationsDashboard.vue b/frontend/src/views/admin/AdminOperationsDashboard.vue new file mode 100644 index 000000000..af366ade2 --- /dev/null +++ b/frontend/src/views/admin/AdminOperationsDashboard.vue @@ -0,0 +1,1129 @@ + + +