From 081215c5c9ddc2d294aac54febff5525fb89ab89 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 25 Jun 2026 18:09:25 +0800 Subject: [PATCH] refactor: polish admin dashboard ui Co-authored-by: Codex --- src/admin/monitoring/dashboard.rs | 102 +++++- tests/admin/monitoring/dashboard.rs | 24 ++ web/src/api/modules/dashboard.ts | 11 + web/src/components/base/BaseBadge.vue | 37 -- web/src/components/base/BaseButton.vue | 28 +- web/src/components/base/BaseCard.vue | 26 -- web/src/components/base/BaseCheckbox.vue | 29 +- web/src/components/base/BaseEmpty.vue | 8 +- web/src/components/base/BaseIconButton.vue | 4 +- web/src/components/base/BaseInput.vue | 16 +- web/src/components/base/BaseScrollbar.vue | 59 +-- web/src/components/base/BaseSegmented.vue | 8 +- web/src/components/base/BaseSelect.vue | 16 +- web/src/components/base/BaseSpinner.vue | 17 - web/src/components/base/BaseTable.vue | 345 ++++++++++++++---- web/src/layout/components/AppSidebar.vue | 78 +++- web/src/layout/components/AppTopbar.vue | 237 ------------ web/src/views/accounts/AccountsView.vue | 18 +- web/src/views/api-keys/ApiKeysView.vue | 8 +- web/src/views/dashboard/DashboardView.vue | 31 +- .../components/AccountOverviewCard.vue | 90 +++-- .../dashboard/components/EventLogCard.vue | 42 +-- .../views/dashboard/components/MetricCard.vue | 61 +++- .../components/RequestHealthTimelineCard.vue | 64 ++++ .../dashboard/components/RequestTrendCard.vue | 43 +-- .../dashboard/composables/useDashboard.ts | 31 ++ web/src/views/dashboard/types.ts | 4 + web/src/views/logs/LogsView.vue | 10 +- web/src/views/settings/SettingsView.vue | 6 +- 29 files changed, 784 insertions(+), 669 deletions(-) delete mode 100644 web/src/components/base/BaseBadge.vue delete mode 100644 web/src/components/base/BaseSpinner.vue delete mode 100644 web/src/layout/components/AppTopbar.vue create mode 100644 web/src/views/dashboard/components/RequestHealthTimelineCard.vue diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index 6dbda90f4..91abee2f0 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -27,12 +27,14 @@ use crate::{ response::{AdminEnvelope, AdminError, AdminResponse}, }, http::middleware::request_id::RequestId, - infra::time::{china_datetime_rfc3339_str, china_relative_time, china_time}, + infra::time::{china_datetime, china_datetime_rfc3339_str, china_relative_time, china_time}, runtime::state::AppState, upstream::accounts::model::{Account, AccountStatus}, }; const DASHBOARD_LOG_LIMIT: u32 = 1000; +const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; +const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -54,6 +56,7 @@ pub enum DashboardTrendKind { pub struct DashboardSummaryData { pub cards: DashboardCardsData, pub trend: DashboardTrendData, + pub health_timeline: DashboardHealthTimelineData, pub account_usage: Vec, pub service_statuses: Vec, pub event_logs: Vec, @@ -62,6 +65,18 @@ pub struct DashboardSummaryData { pub rotation_strategy: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardHealthTimelineData { + pub title: String, + pub description: String, + pub range_display: String, + pub reliability_display: String, + pub oldest_label: String, + pub newest_label: String, + pub points: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DashboardCardsData { @@ -276,6 +291,7 @@ pub async fn dashboard_summary( settings.model.service_tier.as_deref(), ), trend, + health_timeline: dashboard_health_timeline_data(&logs), account_usage: account_usage_data( &accounts, &usage_records, @@ -447,6 +463,82 @@ fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> Dashboar } } +fn dashboard_health_timeline_data(logs: &[EventLog]) -> DashboardHealthTimelineData { + let now = Utc::now(); + let current_slot = quarter_hour_start(now); + let start = current_slot + - Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1)); + let mut buckets = (0..HEALTH_TIMELINE_SLOTS) + .map(|index| { + let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index); + (bucket_start, UsageWindow::default()) + }) + .collect::>(); + + for log in logs { + if log.created_at < start || log.created_at > now { + continue; + } + let log_slot = quarter_hour_start(log.created_at); + if let Some((_, bucket)) = buckets + .iter_mut() + .find(|(bucket_start, _)| *bucket_start == log_slot) + { + apply_log(bucket, log); + } + } + + let requests = buckets + .iter() + .map(|(_, bucket)| bucket.requests) + .sum::(); + let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::(); + let reliability = if requests > 0 { + ((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0 + } else { + 0.0 + }; + let end = current_slot + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES); + + DashboardHealthTimelineData { + title: "请求健康时间线".to_string(), + description: "最近 7 天请求可靠性".to_string(), + range_display: format!("{} - {}", china_datetime(&start), china_datetime(&end)), + reliability_display: if requests > 0 { + format!("{reliability:.1}%") + } else { + "-".to_string() + }, + oldest_label: "最早".to_string(), + newest_label: "最新".to_string(), + points: buckets + .into_iter() + .map(|(_, bucket)| { + let success_rate = if bucket.requests > 0 { + ((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0) + .round() + / 10.0 + } else { + 0.0 + }; + health_tone(bucket, success_rate) + }) + .collect(), + } +} + +fn health_tone(bucket: UsageWindow, success_rate: f64) -> char { + if bucket.requests == 0 { + '0' + } else if bucket.errors == 0 { + '1' + } else if success_rate >= 90.0 { + '2' + } else { + '3' + } +} + fn trend_summary( kind: DashboardTrendKind, points: &[DashboardTrendPointData], @@ -911,3 +1003,11 @@ fn hour_start(value: DateTime) -> DateTime { .expect("valid hour") .and_utc() } + +fn quarter_hour_start(value: DateTime) -> DateTime { + value + .date_naive() + .and_hms_opt(value.hour(), value.minute() / 15 * 15, 0) + .expect("valid quarter hour") + .and_utc() +} diff --git a/tests/admin/monitoring/dashboard.rs b/tests/admin/monitoring/dashboard.rs index 462d32093..ecb11bd63 100644 --- a/tests/admin/monitoring/dashboard.rs +++ b/tests/admin/monitoring/dashboard.rs @@ -213,6 +213,30 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { assert_eq!(body["data"]["accountUsage"][0]["lastUsed"], "2000-01-01"); } +#[tokio::test] +async fn dashboard_summary_should_return_seven_day_health_timeline() { + let (app, store, _pool, _dir) = dashboard_test_app( + "dashboard-health-timeline.sqlite", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + store + .append(&usage_log_with_tokens(Utc::now(), 10)) + .await + .unwrap(); + store + .append(&usage_log_with_tokens(Utc::now() - Duration::days(8), 10)) + .await + .unwrap(); + + let body = dashboard_summary(app).await; + let points = body["data"]["healthTimeline"]["points"].as_str().unwrap(); + + assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线"); + assert_eq!(points.len(), 672); + assert_eq!(points.matches('1').count(), 1); +} + async fn dashboard_summary_total_cost_for_log(db_name: &str, model: &str, metadata: Value) -> f64 { let (app, store, _pool, _dir) = dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await; diff --git a/web/src/api/modules/dashboard.ts b/web/src/api/modules/dashboard.ts index 217d81f56..43d8b7f41 100644 --- a/web/src/api/modules/dashboard.ts +++ b/web/src/api/modules/dashboard.ts @@ -5,6 +5,7 @@ export type DashboardTrendKind = 'usage' | 'latency' | 'errors' export interface DashboardSummary { cards: DashboardCards trend: DashboardTrend + healthTimeline: DashboardHealthTimeline accountUsage: DashboardAccountUsage[] serviceStatuses: DashboardServiceStatus[] eventLogs: DashboardEventLog[] @@ -13,6 +14,16 @@ export interface DashboardSummary { rotationStrategy?: string } +export interface DashboardHealthTimeline { + title: string + description: string + rangeDisplay: string + reliabilityDisplay: string + oldestLabel: string + newestLabel: string + points: string +} + export interface DashboardCards { accounts: DashboardAccountsCard traffic: DashboardTrafficCard diff --git a/web/src/components/base/BaseBadge.vue b/web/src/components/base/BaseBadge.vue deleted file mode 100644 index cd2057e67..000000000 --- a/web/src/components/base/BaseBadge.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/web/src/components/base/BaseButton.vue b/web/src/components/base/BaseButton.vue index 0f05d3c0f..2f9fd4f24 100644 --- a/web/src/components/base/BaseButton.vue +++ b/web/src/components/base/BaseButton.vue @@ -2,41 +2,23 @@ import { LoaderCircle } from '@lucide/vue' import { computed } from 'vue' -type ButtonVariant = - | 'default' - | 'primary' - | 'success' - | 'warning' - | 'danger' - | 'plain' - | 'text' - | 'ghost' -type ButtonSize = 'small' | 'default' | 'large' | 'md' +type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost' const props = withDefaults( defineProps<{ variant?: ButtonVariant - size?: ButtonSize loading?: boolean disabled?: boolean type?: 'button' | 'submit' | 'reset' }>(), { variant: 'primary', - size: 'default', loading: false, disabled: false, type: 'button', }, ) -const sizeClasses = { - large: 'h-10 px-4 gap-2 text-xs rounded-(--cp-button-radius-base)', - default: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)', - md: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)', - small: 'h-6 px-2 gap-1.5 text-[11px] rounded-(--cp-button-radius-small)', -} - const variantClasses = { default: 'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)', @@ -48,25 +30,21 @@ const variantClasses = { 'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)', danger: 'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)', - plain: - 'bg-(--cp-info-bg) text-(--cp-info-text) hover:bg-(--cp-info-bg-hover) active:bg-(--cp-info-bg-active)', - text: 'bg-transparent text-(--cp-info-text) hover:bg-transparent p-0 h-auto', ghost: 'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)', } const classes = computed(() => [ - 'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-all cursor-pointer outline-none', + 'inline-flex h-8 items-center justify-center gap-2 rounded-(--cp-button-radius-base) border-0 px-3 text-xs font-[720] leading-[1.15] transition-all cursor-pointer outline-none', 'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)', 'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none', - sizeClasses[props.size], variantClasses[props.variant], ]) diff --git a/web/src/components/base/BaseCheckbox.vue b/web/src/components/base/BaseCheckbox.vue index 88b791601..2761b4e77 100644 --- a/web/src/components/base/BaseCheckbox.vue +++ b/web/src/components/base/BaseCheckbox.vue @@ -2,44 +2,30 @@ import { Check, Minus } from '@lucide/vue' import { computed } from 'vue' -type CheckboxSize = 'default' | 'table' - const props = withDefaults( defineProps<{ indeterminate?: boolean disabled?: boolean label?: string - showLabel?: boolean - size?: CheckboxSize }>(), { indeterminate: false, disabled: false, label: undefined, - showLabel: false, - size: 'default', }, ) const model = defineModel({ default: false }) const checked = computed(() => model.value || props.indeterminate) -const boxSizeClass = computed(() => (props.size === 'table' ? 'size-4' : 'size-[18px]')) -const rootSizeClass = computed(() => - props.size === 'table' ? 'min-h-4 min-w-4' : 'min-h-[18px] min-w-[18px]', -) -const iconSizeClass = computed(() => (props.size === 'table' ? 'size-3' : 'size-[13px]')) const boxClasses = computed(() => [ - 'relative inline-flex shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150', - boxSizeClass.value, + 'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150', props.disabled ? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)' : checked.value ? 'bg-(--cp-info-bg) text-(--cp-info)' - : props.size === 'table' - ? 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_#CBD5E1]' - : 'bg-(--cp-bg-surface) text-transparent shadow-(--cp-shadow-control)', + : 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_#CBD5E1]', ]) function toggle() { @@ -54,8 +40,8 @@ function toggle() { diff --git a/web/src/components/base/BaseEmpty.vue b/web/src/components/base/BaseEmpty.vue index f6db8feb9..463a0f977 100644 --- a/web/src/components/base/BaseEmpty.vue +++ b/web/src/components/base/BaseEmpty.vue @@ -9,6 +9,7 @@ const props = defineProps<{ description?: string icon?: Component compact?: boolean + plain?: boolean }>() const resolvedTitle = computed(() => props.title ?? props.message ?? '暂无数据') @@ -17,8 +18,11 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)