feat: refine dashboard usage reporting

Track account usage windows from the first use or reset cycle.

Render today's request health timeline with availability levels and polish dashboard card layout.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-01 21:27:25 +08:00
co-authored by Codex
parent c859f1dc91
commit bd8a281baf
13 changed files with 361 additions and 108 deletions
+5 -8
View File
@@ -12,7 +12,7 @@ use axum::{
};
use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{QueryBuilder, Row, Sqlite};
@@ -37,7 +37,7 @@ use crate::{
format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64,
},
json::{clamp_limit, clamp_page, total_pages, Page},
time::{china_datetime, china_relative_time, china_rfc3339},
time::{china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339},
},
runtime::state::AppState,
upstream::accounts::model::AccountStatus,
@@ -1029,10 +1029,11 @@ async fn list_current_window_model_usage(
if index > 0 {
builder.push(" or ");
}
let bucket_start = china_quarter_hour_start(*start);
builder.push("(account_id = ");
builder.push_bind(account_id);
builder.push(" and bucket_start >= ");
builder.push_bind(start.to_rfc3339());
builder.push_bind(bucket_start.to_rfc3339());
builder.push(" and bucket_start <= ");
builder.push_bind(end.to_rfc3339());
builder.push(")");
@@ -1095,11 +1096,7 @@ fn current_usage_window(
usage: &AdminUsageRecord,
now: DateTime<Utc>,
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let start = usage.window_started_at.or_else(|| {
let reset_at = usage.window_reset_at?;
let seconds = i64::try_from(usage.limit_window_seconds?).ok()?;
Some(reset_at - Duration::seconds(seconds))
})?;
let start = usage.window_started_at?;
let end = usage.window_reset_at.unwrap_or(now);
(start <= end).then_some((start, end))
}
+90 -20
View File
@@ -41,7 +41,9 @@ use crate::{
const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000;
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4;
const DASHBOARD_TIME_BUCKET_SLOTS: i64 = 7 * 24 * 4;
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -432,7 +434,7 @@ async fn dashboard_time_buckets(
) -> Vec<AdminUsageTimeBucketRecord> {
let current_slot = china_quarter_hour_start(now);
let start = current_slot
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (DASHBOARD_TIME_BUCKET_SLOTS - 1));
state
.services
.usage
@@ -530,8 +532,7 @@ fn dashboard_health_timeline_data_at(
now: DateTime<Utc>,
) -> DashboardHealthTimelineData {
let current_slot = china_quarter_hour_start(now);
let start = current_slot
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
.map(|index| {
let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index);
@@ -554,52 +555,121 @@ fn dashboard_health_timeline_data_at(
let requests = buckets
.iter()
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
.map(|(_, bucket)| bucket.requests)
.sum::<u64>();
let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::<u64>();
let errors = buckets
.iter()
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
.map(|(_, bucket)| bucket.errors)
.sum::<u64>();
let successes = requests.saturating_sub(errors);
let reliability = if requests > 0 {
((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0
(successes 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)),
description: "今日请求可靠性".to_string(),
range_display: format!("{} - {}", china_datetime(&start), china_datetime(&now)),
reliability_display: if requests > 0 {
format!("{reliability:.1}%")
} else {
"-".to_string()
},
oldest_label: "最早".to_string(),
newest_label: "最新".to_string(),
oldest_label: "00:00".to_string(),
newest_label: "24:00".to_string(),
points: buckets
.into_iter()
.map(|(_, bucket)| {
.map(|(bucket_start, bucket)| {
let success_rate = if bucket.requests > 0 {
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
(bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64
* 1000.0)
.round()
/ 10.0
} else {
0.0
};
health_tone(bucket, success_rate)
health_tone(bucket, success_rate, bucket_start > current_slot)
})
.collect(),
}
}
fn health_tone(bucket: UsageWindow, success_rate: f64) -> char {
if bucket.requests == 0 {
fn health_tone(bucket: UsageWindow, success_rate: f64, is_future: bool) -> char {
let successes = bucket.requests.saturating_sub(bucket.errors);
if is_future {
'0'
} else if bucket.errors == 0 {
} else if bucket.requests == 0 {
'1'
} else if success_rate >= 90.0 {
} else if successes == 0 {
'2'
} else {
} else if success_rate < 90.0 {
'3'
} else if successes < HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD {
'4'
} else {
'5'
}
}
#[cfg(test)]
mod tests {
use super::{health_tone, UsageWindow};
#[test]
fn health_tone_should_classify_availability_levels() {
assert_eq!(health_tone(UsageWindow::default(), 0.0, true), '0');
assert_eq!(health_tone(UsageWindow::default(), 0.0, false), '1');
assert_eq!(
health_tone(
UsageWindow {
requests: 2,
errors: 2,
..UsageWindow::default()
},
0.0,
false,
),
'2'
);
assert_eq!(
health_tone(
UsageWindow {
requests: 10,
errors: 2,
..UsageWindow::default()
},
80.0,
false,
),
'3'
);
assert_eq!(
health_tone(
UsageWindow {
requests: 2,
errors: 0,
..UsageWindow::default()
},
100.0,
false,
),
'4'
);
assert_eq!(
health_tone(
UsageWindow {
requests: 3,
errors: 0,
..UsageWindow::default()
},
100.0,
false,
),
'5'
);
}
}
+1 -1
View File
@@ -718,8 +718,8 @@ impl AccountPool {
account.request_count = account.request_count.saturating_add(1);
account.window_request_count = account.window_request_count.saturating_add(1);
if account.window_started_at.is_none() {
account.window_started_at = Some(now);
if let Some(seconds) = account.limit_window_seconds {
account.window_started_at = Some(now);
let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
account.window_reset_at = Some(now + Duration::seconds(seconds));
}
+5 -8
View File
@@ -293,8 +293,9 @@ insert into account_usage (
window_image_output_tokens,
window_image_request_count,
window_image_request_failed_count,
window_started_at,
last_used_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(account_id) do update set
request_count = request_count + excluded.request_count,
empty_response_count = empty_response_count + excluded.empty_response_count,
@@ -315,13 +316,8 @@ on conflict(account_id) do update set
window_image_output_tokens = window_image_output_tokens + excluded.window_image_output_tokens,
window_image_request_count = window_image_request_count + excluded.window_image_request_count,
window_image_request_failed_count = window_image_request_failed_count + excluded.window_image_request_failed_count,
window_started_at = case
when account_usage.window_started_at is null
and (account_usage.window_reset_at is not null or account_usage.limit_window_seconds is not null)
then excluded.last_used_at
else account_usage.window_started_at
end,
last_used_at = excluded.last_used_at";
window_started_at = coalesce(account_usage.window_started_at, excluded.window_started_at),
last_used_at = excluded.last_used_at";
const RECORD_MODEL_USAGE_SQL: &str = r"
insert into account_model_usage (
@@ -1293,6 +1289,7 @@ impl SqliteAccountStore {
.bind(delta.window_image_request_count)
.bind(delta.window_image_request_failed_count)
.bind(&last_used_at)
.bind(&last_used_at)
.execute(&self.pool)
.await?;
Ok(())
+134
View File
@@ -252,6 +252,140 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
assert_eq!(item["usage"]["models"][1]["model"], "gpt-5");
}
#[tokio::test]
async fn admin_accounts_list_should_require_current_window_start_for_model_stats() {
let (app, _state, pool, _dir) =
admin_accounts_test_app("admin-accounts-model-window-start.sqlite", 69).await;
sqlx::query("insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, 'active', ?, ?)")
.bind("acct_no_window_start")
.bind("stats@example.com")
.bind("access-token")
.bind("2026-06-11T00:00:00Z")
.bind("2026-06-11T00:00:00Z")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"insert into account_usage (
account_id,
request_count,
window_request_count,
window_input_tokens,
window_output_tokens,
window_cached_tokens,
window_reset_at,
limit_window_seconds,
last_used_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("acct_no_window_start")
.bind(3)
.bind(3)
.bind(120)
.bind(40)
.bind(20)
.bind("2026-06-27T00:00:00Z")
.bind(604_800)
.bind("2026-06-23T08:50:13Z")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("2026-06-23T08:45:00+00:00")
.bind("acct_no_window_start")
.bind("gpt-5.5")
.bind(3)
.bind(120)
.bind(40)
.bind(20)
.bind("2026-06-23T08:45:00Z")
.execute(&pool)
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts?page=1&pageSize=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_model_window_start")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let item = &body["data"]["items"][0];
assert_eq!(item["usage"]["models"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn admin_accounts_list_should_include_bucket_containing_window_start_for_model_stats() {
let (app, _state, pool, _dir) =
admin_accounts_test_app("admin-accounts-model-window-bucket-start.sqlite", 70).await;
seed_usage_account(
&pool,
UsageAccountSeed {
id: "acct_bucket_start",
email: "bucket@example.com",
label: "bucket",
plan_type: "free",
request_count: 2,
empty_response_count: 0,
input_tokens: 120,
output_tokens: 40,
cached_tokens: 20,
window_request_count: 2,
window_input_tokens: 120,
window_output_tokens: 40,
window_cached_tokens: 20,
window_started_at: "2026-06-29T12:56:15.098980168+00:00",
window_reset_at: "2026-07-29T12:56:15.098980168+00:00",
limit_window_seconds: 2_592_000,
last_used_at: "2026-06-29T12:59:42.044220420+00:00",
},
)
.await;
sqlx::query(
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("2026-06-29T12:45:00+00:00")
.bind("acct_bucket_start")
.bind("gpt-5.5")
.bind(2)
.bind(120)
.bind(40)
.bind(20)
.bind("2026-06-29T12:59:42.044220420+00:00")
.execute(&pool)
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts?page=1&pageSize=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_model_window_bucket_start")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let item = &body["data"]["items"][0];
assert_eq!(item["usage"]["models"][0]["model"], "gpt-5.5");
assert_eq!(item["usage"]["models"][0]["requestCount"], 2);
}
#[tokio::test]
async fn admin_accounts_list_should_return_numbered_page_with_search_total() {
let (app, _state, pool, _dir) =
+7 -3
View File
@@ -322,7 +322,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
}
#[tokio::test]
async fn dashboard_summary_should_return_seven_day_health_timeline() {
async fn dashboard_summary_should_return_today_health_timeline() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-health-timeline.sqlite",
crate::support::fingerprint::test_fingerprint(),
@@ -344,8 +344,12 @@ async fn dashboard_summary_should_return_seven_day_health_timeline() {
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);
assert_eq!(
body["data"]["healthTimeline"]["description"],
"今日请求可靠性"
);
assert_eq!(points.len(), 96);
assert_eq!(points.matches('4').count(), 1);
}
async fn dashboard_summary_total_cost_for_usage_record(
@@ -116,6 +116,26 @@ fn release_should_mark_runtime_usage_window() {
assert!(released.last_used_at.is_some());
}
#[test]
fn release_should_start_usage_window_without_known_reset() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(crate::support::accounts::test_account(
"acct_a",
AccountStatus::Active,
));
let acquired = pool
.acquire_with(&AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
pool.release(&acquired.account.id);
let released = pool.get(&acquired.account.id).unwrap();
assert!(released.window_started_at.is_some());
assert!(released.window_reset_at.is_none());
}
#[test]
fn release_should_mark_usage_after_stale_slot_cleanup() {
let now = fixed_time();
@@ -252,10 +252,21 @@ async fn account_repository_should_accumulate_usage_counters() {
.await
.unwrap();
let usage: (i64, i64, i64, i64, i64, i64, i64, Option<String>) = sqlx::query_as(
let usage: (
i64,
i64,
i64,
i64,
i64,
i64,
i64,
Option<String>,
Option<String>,
) = sqlx::query_as(
r"
select request_count, input_tokens, output_tokens, cached_tokens,
reasoning_tokens, total_tokens, empty_response_count, last_used_at
reasoning_tokens, total_tokens, empty_response_count,
window_started_at, last_used_at
from account_usage
where account_id = ?
",
@@ -272,6 +283,7 @@ async fn account_repository_should_accumulate_usage_counters() {
assert_eq!(usage.5, 24);
assert_eq!(usage.6, 1);
assert!(usage.7.is_some());
assert!(usage.8.is_some());
}
#[tokio::test]
@@ -138,61 +138,56 @@ const quotaToneClasses: Record<string, string> = {
<div
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
>
<section
class="min-w-0 w-full shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:h-100.5 xl:pb-0 xl:shadow-none"
>
<section class="min-w-0 w-full pb-6 xl:h-100.5 xl:pb-0">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
容量、并发与分配策略
</p>
<div class="mt-7.75 h-30.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.5">
<div class="mt-7.75 grid h-30.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>槽位占用</span
>
<div class="mt-3 grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
<strong
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ usedRatio }}</strong
>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>{{ usedPercent }}% 已占用</span
>
</div>
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
<i
class="block h-2.5 rounded-full bg-(--cp-success)"
:style="{ width: `${usedPercent}%` }"
/>
<div>
<div class="grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
<strong
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ usedRatio }}</strong
>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>{{ usedPercent }}% 已占用</span
>
</div>
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
<i
class="block h-2.5 rounded-full bg-(--cp-success)"
:style="{ width: `${usedPercent}%` }"
/>
</div>
</div>
</div>
<div
class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-5"
>
<div v-for="stat in scheduleStats" :key="stat.label">
<div class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) p-4">
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
stat.label
}}</span>
<strong
class="mt-3.25 block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
class="block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ stat.value }}</strong
>
</div>
</div>
<div class="mt-4 h-20.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.25">
<div class="mt-4 grid h-20.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">分配策略</span>
<strong
class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ strategyLabel }}</strong
>
<strong class="block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{
strategyLabel
}}</strong>
</div>
</section>
<section
class="min-w-0 w-full py-6 shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:py-0 xl:shadow-none"
>
<section class="min-w-0 w-full">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
最近使用排序
@@ -7,12 +7,31 @@ const props = defineProps<{
timeline: any
}>()
const points = computed(() => props.timeline.points.split(''))
const healthLegend = [
{ code: '0', label: '未来' },
{ code: '1', label: '无请求' },
{ code: '2', label: '不可达' },
{ code: '3', label: '不稳定' },
{ code: '4', label: '低样本' },
{ code: '5', label: '稳定' },
]
const points = computed(() => String(props.timeline.points ?? '').split(''))
const pointGroups = computed(() => {
const groups: string[][] = []
for (let index = 0; index < points.value.length; index += 4) {
groups.push(points.value.slice(index, index + 4))
}
return groups
})
function cellClass(point: string) {
if (point === '1') return 'bg-(--cp-success)'
if (point === '2') return 'bg-(--cp-warning)'
if (point === '3') return 'bg-(--cp-danger)'
if (point === '0') return 'bg-(--cp-disabled-bg) opacity-60'
if (point === '1') return 'bg-(--cp-default-border-hover)'
if (point === '2') return 'bg-(--cp-danger)'
if (point === '3') return 'bg-(--cp-warning)'
if (point === '4') return 'bg-(--cp-normal)'
if (point === '5') return 'bg-(--cp-success)'
return 'bg-(--cp-default-border-hover)'
}
</script>
@@ -27,35 +46,46 @@ function cellClass(point: string) {
class="w-full"
>
<template #actions>
<div class="grid gap-2 lg:justify-items-end">
<span class="font-mono text-xs leading-none font-[650] text-(--cp-text-secondary)">
{{ timeline.rangeDisplay }}
</span>
<strong class="font-mono text-sm leading-none font-[760] text-(--cp-success-text)">
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
>
<span
v-for="item in healthLegend"
:key="item.code"
class="inline-flex h-3.5 items-center gap-1 align-middle leading-none"
>
<span
aria-hidden="true"
class="block size-2 shrink-0 rounded-xs"
:class="cellClass(item.code)"
/>
<span class="block leading-none">{{ item.label }}</span>
</span>
</div>
<strong
class="shrink-0 font-mono text-sm leading-none font-[760] tabular-nums text-(--cp-success-text)"
>
{{ timeline.reliabilityDisplay }}
</strong>
<div
class="flex items-center gap-2 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
>
<span>{{ timeline.oldestLabel }}</span>
<i class="size-2 rounded-xs bg-(--cp-default-border-hover)" />
<i class="size-2 rounded-xs bg-(--cp-danger)" />
<i class="size-2 rounded-xs bg-(--cp-warning)" />
<i class="size-2 rounded-xs bg-(--cp-success)" />
<span>{{ timeline.newestLabel }}</span>
</div>
</div>
</template>
<template #body>
<div class="mt-4.25">
<div class="flex flex-wrap gap-1">
<i
v-for="(point, index) in points"
:key="index"
class="size-2.5 rounded-[3px]"
:class="cellClass(point)"
/>
<div class="grid w-full grid-cols-[repeat(24,minmax(0,1fr))] items-end gap-[3px]">
<div
v-for="(group, groupIndex) in pointGroups"
:key="groupIndex"
class="grid grid-cols-4 items-end gap-[2px]"
>
<i
v-for="(point, pointIndex) in group"
:key="groupIndex * 4 + pointIndex"
class="h-3.5 min-w-[2px] rounded-[2px]"
:class="cellClass(point)"
/>
</div>
</div>
</div>
</template>
@@ -229,13 +229,7 @@ function summaryMarkerStyle(item: any) {
</script>
<template>
<BaseCard
as="article"
variant="dashboard"
title="使用趋势"
description="最近 24 小时"
class="min-h-95 w-full"
>
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
<template #actions>
<BaseSegmented
v-model="activeTab"
@@ -141,7 +141,7 @@ export function useDashboard(): any {
},
healthTimeline: {
title: '请求健康时间线',
description: '最近 7 天请求可靠性',
description: '今日请求可靠性',
rangeDisplay: '-',
reliabilityDisplay: '-',
oldestLabel: '最早',
+1 -1
View File
@@ -38,7 +38,7 @@ const {
系统概览
</h1>
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
生产环境 · 自动刷新 30s
今日统计 · 自动刷新 30s
</p>
</div>