feat: add new StatsPage for enhanced statistics overview#7152
feat: add new StatsPage for enhanced statistics overview#7152
Conversation
- Introduced StatsPage.vue to provide a comprehensive overview of statistics with various metrics and charts. - Implemented fetching and displaying of base and model provider statistics. - Added unit tests for provider statistics persistence in the database.
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The
aggregateOverflowSerieshelper inStatsPage.vueassumes all series have the same number of points at identical timestamps and sums by array index; consider aggregating by timestamp key instead to avoid subtle chart inaccuracies if the backend ever returns uneven series. - The new
StatsPage.vueis quite large and mixes data fetching, chart configuration, and layout in a single file; consider extracting composables (for fetching/formatting logic) or smaller presentational components (e.g., overview cards, provider ranking list) to keep the view more maintainable.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `aggregateOverflowSeries` helper in `StatsPage.vue` assumes all series have the same number of points at identical timestamps and sums by array index; consider aggregating by timestamp key instead to avoid subtle chart inaccuracies if the backend ever returns uneven series.
- The new `StatsPage.vue` is quite large and mixes data fetching, chart configuration, and layout in a single file; consider extracting composables (for fetching/formatting logic) or smaller presentational components (e.g., overview cards, provider ranking list) to keep the view more maintainable.
## Individual Comments
### Comment 1
<location path="astrbot/core/db/sqlite.py" line_range="212-216" />
<code_context>
+ end_time=end_time,
+ time_to_first_token=time_to_first_token,
+ )
+ session.add(record)
+ await session.flush()
+ await session.refresh(record)
+ return record
+
</code_context>
<issue_to_address>
**suggestion (performance):** Refreshing the record after flush/begin is probably unnecessary overhead.
Here you `add(record)`, `flush()`, then `refresh(record)` before returning. For a simple insert where only the autoincrement PK is DB-generated, `flush()` already populates `record.id`. The extra `refresh()` adds an unnecessary DB round-trip. Unless you depend on DB-side defaults/triggers updating other fields, you can remove `await session.refresh(record)` to avoid that overhead.
```suggestion
)
session.add(record)
await session.flush()
return record
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| ) | ||
| session.add(record) | ||
| await session.flush() | ||
| await session.refresh(record) | ||
| return record |
There was a problem hiding this comment.
suggestion (performance): Refreshing the record after flush/begin is probably unnecessary overhead.
Here you add(record), flush(), then refresh(record) before returning. For a simple insert where only the autoincrement PK is DB-generated, flush() already populates record.id. The extra refresh() adds an unnecessary DB round-trip. Unless you depend on DB-side defaults/triggers updating other fields, you can remove await session.refresh(record) to avoid that overhead.
| ) | |
| session.add(record) | |
| await session.flush() | |
| await session.refresh(record) | |
| return record | |
| ) | |
| session.add(record) | |
| await session.flush() | |
| return record |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive system statistics dashboard. It adds a new database model, ProviderStat, to track per-response LLM provider metrics such as token usage, time-to-first-token, and total duration. The backend now supports asynchronous recording of these statistics and provides a new API endpoint for time-based aggregation (1, 3, or 7 days). The frontend has been overhauled with a new StatsPage featuring interactive ApexCharts, overview cards, and detailed rankings for platforms and models, including full i18n support. Feedback focuses on refactoring the large backend aggregation logic and the monolithic Vue component into smaller, more maintainable modules to improve readability and testability.
| async def get_provider_token_stats(self): | ||
| try: | ||
| try: | ||
| days = int(request.args.get("days", 1)) | ||
| except (TypeError, ValueError): | ||
| days = 1 | ||
| if days not in (1, 3, 7): | ||
| days = 1 | ||
|
|
||
| local_tz = datetime.now().astimezone().tzinfo or timezone.utc | ||
| now_local = datetime.now(local_tz) | ||
| range_start_local = (now_local - timedelta(days=days)).replace( | ||
| minute=0, second=0, microsecond=0 | ||
| ) | ||
| today_start_local = now_local.replace( | ||
| hour=0, minute=0, second=0, microsecond=0 | ||
| ) | ||
| query_start_local = min(range_start_local, today_start_local) | ||
| query_start_utc = query_start_local.astimezone(timezone.utc) | ||
|
|
||
| async with self.db_helper.get_db() as session: | ||
| result = await session.execute( | ||
| select(ProviderStat) | ||
| .where( | ||
| ProviderStat.agent_type == "internal", | ||
| ProviderStat.created_at >= query_start_utc, | ||
| ) | ||
| .order_by(ProviderStat.created_at.asc()) | ||
| ) | ||
| records = result.scalars().all() | ||
|
|
||
| bucket_timestamps: list[int] = [] | ||
| bucket_cursor = range_start_local | ||
| while bucket_cursor <= now_local: | ||
| bucket_timestamps.append(int(bucket_cursor.timestamp() * 1000)) | ||
| bucket_cursor += timedelta(hours=1) | ||
|
|
||
| trend_by_provider: dict[str, dict[int, int]] = defaultdict( | ||
| lambda: defaultdict(int) | ||
| ) | ||
| total_by_provider: dict[str, int] = defaultdict(int) | ||
| total_by_umo: dict[str, int] = defaultdict(int) | ||
| total_by_bucket: dict[int, int] = defaultdict(int) | ||
| range_total_tokens = 0 | ||
| range_total_calls = 0 | ||
| range_success_calls = 0 | ||
| range_ttft_total_ms = 0.0 | ||
| range_ttft_samples = 0 | ||
| range_duration_total_ms = 0.0 | ||
| range_duration_samples = 0 | ||
| today_by_model: dict[str, int] = defaultdict(int) | ||
| today_by_provider: dict[str, int] = defaultdict(int) | ||
| today_total_tokens = 0 | ||
| today_total_calls = 0 | ||
|
|
||
| for record in records: | ||
| created_at_utc = self._ensure_aware_utc(record.created_at) | ||
| created_at_local = created_at_utc.astimezone(local_tz) | ||
| token_total = ( | ||
| record.token_input_other | ||
| + record.token_input_cached | ||
| + record.token_output | ||
| ) | ||
| provider_id = record.provider_id or "unknown" | ||
| provider_model = record.provider_model or "Unknown" | ||
|
|
||
| if created_at_local >= range_start_local: | ||
| bucket_local = created_at_local.replace( | ||
| minute=0, second=0, microsecond=0 | ||
| ) | ||
| bucket_ts = int(bucket_local.timestamp() * 1000) | ||
| trend_by_provider[provider_id][bucket_ts] += token_total | ||
| total_by_provider[provider_id] += token_total | ||
| total_by_umo[record.umo or "unknown"] += token_total | ||
| total_by_bucket[bucket_ts] += token_total | ||
| range_total_tokens += token_total | ||
| range_total_calls += 1 | ||
| if record.status != "error": | ||
| range_success_calls += 1 | ||
| if record.time_to_first_token > 0: | ||
| range_ttft_total_ms += record.time_to_first_token * 1000 | ||
| range_ttft_samples += 1 | ||
| if record.end_time > record.start_time: | ||
| range_duration_total_ms += ( | ||
| record.end_time - record.start_time | ||
| ) * 1000 | ||
| range_duration_samples += 1 | ||
|
|
||
| if created_at_local >= today_start_local: | ||
| today_total_calls += 1 | ||
| today_total_tokens += token_total | ||
| today_by_model[provider_model] += token_total | ||
| today_by_provider[provider_id] += token_total | ||
|
|
||
| sorted_provider_ids = sorted( | ||
| total_by_provider.keys(), | ||
| key=lambda item: total_by_provider[item], | ||
| reverse=True, | ||
| ) | ||
|
|
||
| series = [ | ||
| { | ||
| "name": provider_id, | ||
| "data": [ | ||
| [bucket_ts, trend_by_provider[provider_id].get(bucket_ts, 0)] | ||
| for bucket_ts in bucket_timestamps | ||
| ], | ||
| "total_tokens": total_by_provider[provider_id], | ||
| } | ||
| for provider_id in sorted_provider_ids | ||
| ] | ||
|
|
||
| total_series = [ | ||
| [bucket_ts, total_by_bucket.get(bucket_ts, 0)] | ||
| for bucket_ts in bucket_timestamps | ||
| ] | ||
|
|
||
| today_by_model_data = [ | ||
| {"provider_model": model_name, "tokens": tokens} | ||
| for model_name, tokens in sorted( | ||
| today_by_model.items(), | ||
| key=lambda item: item[1], | ||
| reverse=True, | ||
| ) | ||
| ] | ||
| today_by_provider_data = [ | ||
| {"provider_id": provider_id, "tokens": tokens} | ||
| for provider_id, tokens in sorted( | ||
| today_by_provider.items(), | ||
| key=lambda item: item[1], | ||
| reverse=True, | ||
| ) | ||
| ] | ||
| range_by_provider_data = [ | ||
| {"provider_id": provider_id, "tokens": tokens} | ||
| for provider_id, tokens in sorted( | ||
| total_by_provider.items(), | ||
| key=lambda item: item[1], | ||
| reverse=True, | ||
| ) | ||
| ] | ||
| range_by_umo_data = [ | ||
| {"umo": umo, "tokens": tokens} | ||
| for umo, tokens in sorted( | ||
| total_by_umo.items(), | ||
| key=lambda item: item[1], | ||
| reverse=True, | ||
| ) | ||
| ] | ||
|
|
||
| return ( | ||
| Response() | ||
| .ok( | ||
| { | ||
| "days": days, | ||
| "trend": { | ||
| "series": series, | ||
| "total_series": total_series, | ||
| }, | ||
| "range_total_tokens": range_total_tokens, | ||
| "range_total_calls": range_total_calls, | ||
| "range_avg_ttft_ms": ( | ||
| range_ttft_total_ms / range_ttft_samples | ||
| if range_ttft_samples | ||
| else 0 | ||
| ), | ||
| "range_avg_duration_ms": ( | ||
| range_duration_total_ms / range_duration_samples | ||
| if range_duration_samples | ||
| else 0 | ||
| ), | ||
| "range_avg_tpm": ( | ||
| range_total_tokens / (range_duration_total_ms / 1000 / 60) | ||
| if range_duration_total_ms > 0 | ||
| else 0 | ||
| ), | ||
| "range_success_rate": ( | ||
| range_success_calls / range_total_calls | ||
| if range_total_calls | ||
| else 0 | ||
| ), | ||
| "range_by_provider": range_by_provider_data, | ||
| "range_by_umo": range_by_umo_data, | ||
| "today_total_tokens": today_total_tokens, | ||
| "today_total_calls": today_total_calls, | ||
| "today_by_model": today_by_model_data, | ||
| "today_by_provider": today_by_provider_data, | ||
| } | ||
| ) | ||
| .__dict__ | ||
| ) | ||
| except Exception as e: | ||
| logger.error(traceback.format_exc()) | ||
| return Response().error(f"Error: {e!s}").__dict__ | ||
|
|
There was a problem hiding this comment.
This method is quite long and handles multiple responsibilities: fetching data, complex time-based aggregation, and formatting the final response. This reduces readability and makes it harder to test and maintain.
Consider refactoring this logic into smaller, more focused components:
- Data Fetching: A dedicated function to query and retrieve ProviderStat records.
- Aggregation Logic: A separate class or function to process the raw records. This component would handle the bucketing, timezone conversions, and calculations for totals, averages, etc.
- Response Formatting: A function to take the aggregated data and structure it into the final JSON response.
Additionally, for performance and scalability, it's worth noting that fetching all records and aggregating in Python can be memory-intensive. While the current approach is easier to implement given the timezone and bucketing complexity, consider moving some of the aggregation logic to the database using SQL GROUP BY and aggregate functions in the future if performance becomes a concern.
| <template> | ||
| <div class="stats-page" :class="{ 'is-dark': isDark }"> | ||
| <v-container fluid class="stats-shell pa-4 pa-md-6"> | ||
| <div class="stats-header"> | ||
| <div> | ||
| <div class="eyebrow">{{ t('header.eyebrow') }}</div> | ||
| <h1 class="stats-title">{{ t('header.title') }}</h1> | ||
| <p class="stats-subtitle">{{ t('header.subtitle') }}</p> | ||
| </div> | ||
| <div class="header-meta"> | ||
| <div class="meta-pill"> | ||
| <v-icon size="16">mdi-refresh</v-icon> | ||
| <span>{{ lastUpdatedLabel }}</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <v-alert | ||
| v-if="errorMessage" | ||
| type="error" | ||
| variant="tonal" | ||
| class="mb-4" | ||
| > | ||
| {{ errorMessage }} | ||
| </v-alert> | ||
|
|
||
| <div v-if="loading && !baseStats" class="loading-wrap"> | ||
| <v-progress-circular indeterminate color="grey-darken-1" /> | ||
| </div> | ||
|
|
||
| <template v-else> | ||
| <div class="overview-grid"> | ||
| <section | ||
| v-for="card in overviewCards" | ||
| :key="card.label" | ||
| class="stat-card overview-card" | ||
| > | ||
| <div class="card-icon"> | ||
| <v-icon size="18">{{ card.icon }}</v-icon> | ||
| </div> | ||
| <div class="card-label">{{ card.label }}</div> | ||
| <div class="card-value">{{ card.value }}</div> | ||
| <div class="card-note">{{ card.note }}</div> | ||
| </section> | ||
| </div> | ||
|
|
||
| <div class="section-toolbar"> | ||
| <div> | ||
| <div class="section-title">{{ t('messageOverview.title') }}</div> | ||
| <div class="section-subtitle">{{ t('messageOverview.subtitle') }}</div> | ||
| </div> | ||
| <div class="range-switch"> | ||
| <button | ||
| v-for="option in rangeOptions" | ||
| :key="`toolbar-${option.value}`" | ||
| type="button" | ||
| class="range-chip" | ||
| :class="{ active: selectedRange === option.value }" | ||
| @click="selectedRange = option.value" | ||
| > | ||
| {{ t(option.labelKey) }} | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="panel-grid"> | ||
| <section class="stat-card chart-card chart-card-wide"> | ||
| <div class="card-head"> | ||
| <div> | ||
| <div class="section-title">{{ t('messageTrend.title') }}</div> | ||
| <div class="section-subtitle">{{ t('messageTrend.subtitle', { range: rangeLabel }) }}</div> | ||
| </div> | ||
| <div class="card-head-actions"> | ||
| <div class="section-metric"> | ||
| <span class="metric-label">{{ t('messageTrend.totalMessages') }}</span> | ||
| <span class="metric-value">{{ formatNumber(baseStats?.message_count ?? 0) }}</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <apexchart | ||
| type="area" | ||
| height="320" | ||
| :options="messageChartOptions" | ||
| :series="messageChartSeries" | ||
| /> | ||
| </section> | ||
|
|
||
| <section class="stat-card provider-list-card"> | ||
| <div class="card-head compact"> | ||
| <div> | ||
| <div class="section-title">{{ t('platformRanking.title') }}</div> | ||
| <div class="section-subtitle">{{ t('platformRanking.subtitle', { range: rangeLabel }) }}</div> | ||
| </div> | ||
| </div> | ||
| <div v-if="platformRanking.length" class="provider-list"> | ||
| <div | ||
| v-for="platform in platformRanking" | ||
| :key="platform.name" | ||
| class="provider-row" | ||
| > | ||
| <span class="provider-name">{{ platform.name }}</span> | ||
| <strong>{{ formatNumber(platform.count) }}</strong> | ||
| </div> | ||
| </div> | ||
| <div v-else class="empty-state">{{ t('empty.platformStats') }}</div> | ||
| </section> | ||
| </div> | ||
|
|
||
| <div class="token-section-head"> | ||
| <div> | ||
| <div class="section-title">{{ t('modelCalls.title') }}</div> | ||
| <div class="section-subtitle">{{ t('modelCalls.subtitle') }}</div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="token-grid"> | ||
| <section class="stat-card chart-card chart-card-wide provider-trend-card"> | ||
| <div class="card-head"> | ||
| <div> | ||
| <div class="section-title">{{ t('modelTrend.title') }}</div> | ||
| <div class="section-subtitle">{{ t('modelTrend.subtitle') }}</div> | ||
| </div> | ||
| </div> | ||
| <apexchart | ||
| type="bar" | ||
| height="420" | ||
| :options="providerChartOptions" | ||
| :series="providerTrendSeries" | ||
| /> | ||
| </section> | ||
|
|
||
| <section class="token-side-column"> | ||
| <section class="stat-card token-total-card"> | ||
| <div class="card-label">{{ t('modelTotal.title', { range: rangeLabel }) }}</div> | ||
| <div class="token-total-value">{{ formatNumber(providerStats?.range_total_tokens ?? 0) }} <span style="font-size: 18px;">{{ t('units.tokens') }}</span></div> | ||
| <div class="card-note">{{ t('modelTotal.callCount', { count: formatNumber(providerStats?.range_total_calls ?? 0) }) }}</div> | ||
| <div class="token-meta-list"> | ||
| <div class="token-meta-item"> | ||
| <span>{{ t('modelTotal.avgTtft') }}</span> | ||
| <strong>{{ rangeAvgTtftLabel }}</strong> | ||
| </div> | ||
| <div class="token-meta-item"> | ||
| <span>{{ t('modelTotal.avgDuration') }}</span> | ||
| <strong>{{ rangeAvgDurationLabel }}</strong> | ||
| </div> | ||
| <div class="token-meta-item"> | ||
| <span>{{ t('modelTotal.avgTpm') }}</span> | ||
| <strong>{{ rangeAvgTpmLabel }}</strong> | ||
| </div> | ||
| <div class="token-meta-item"> | ||
| <span>{{ t('modelTotal.successRate') }}</span> | ||
| <strong>{{ rangeSuccessRateLabel }}</strong> | ||
| </div> | ||
| </div> | ||
| </section> | ||
|
|
||
| <section class="stat-card provider-list-card"> | ||
| <div class="card-head compact"> | ||
| <div> | ||
| <div class="section-title">{{ t('modelRanking.title', { range: rangeLabel }) }}</div> | ||
| <div class="section-subtitle">{{ t('modelRanking.subtitle') }}</div> | ||
| </div> | ||
| </div> | ||
| <div | ||
| v-if="rangeProviderRanking.length" | ||
| class="provider-list provider-list--scrollable" | ||
| > | ||
| <div | ||
| v-for="provider in rangeProviderRanking" | ||
| :key="provider.provider_id" | ||
| class="provider-row" | ||
| > | ||
| <span class="provider-name">{{ provider.provider_id }}</span> | ||
| <strong>{{ formatNumber(provider.tokens) }}</strong> | ||
| </div> | ||
| </div> | ||
| <div v-else class="empty-state">{{ t('empty.modelCalls', { range: rangeLabel }) }}</div> | ||
| </section> | ||
| </section> | ||
| </div> | ||
|
|
||
| <section class="stat-card provider-list-card"> | ||
| <div class="card-head compact"> | ||
| <div> | ||
| <div class="section-title">{{ t('sessionRanking.title', { range: rangeLabel }) }}</div> | ||
| <div class="section-subtitle">{{ t('sessionRanking.subtitle') }}</div> | ||
| </div> | ||
| </div> | ||
| <div v-if="rangeUmoRanking.length" class="provider-list"> | ||
| <div | ||
| v-for="item in rangeUmoRanking" | ||
| :key="item.umo" | ||
| class="provider-row" | ||
| > | ||
| <span class="provider-name">{{ item.umo }}</span> | ||
| <strong>{{ formatNumber(item.tokens) }}</strong> | ||
| </div> | ||
| </div> | ||
| <div v-else class="empty-state">{{ t('empty.sessionCalls', { range: rangeLabel }) }}</div> | ||
| </section> | ||
| </template> | ||
| </v-container> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import type { ApexOptions } from 'apexcharts' | ||
| import axios from 'axios' | ||
| import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' | ||
| import { useTheme } from 'vuetify' | ||
| import { useI18n, useModuleI18n } from '@/i18n/composables' | ||
|
|
||
| type TokenRange = 1 | 3 | 7 | ||
| type ChartSeries = Array<{ | ||
| name: string | ||
| data: unknown[] | ||
| }> | ||
|
|
||
| interface RunningStats { | ||
| hours: number | ||
| minutes: number | ||
| seconds: number | ||
| } | ||
|
|
||
| interface BaseStatsResponse { | ||
| message_count: number | ||
| platform_count: number | ||
| platform: Array<{ | ||
| name: string | ||
| count: number | ||
| timestamp: number | ||
| }> | ||
| message_time_series: Array<[number, number]> | ||
| memory: { | ||
| process: number | ||
| system: number | ||
| } | ||
| cpu_percent: number | ||
| running: RunningStats | ||
| thread_count: number | ||
| start_time: number | ||
| } | ||
|
|
||
| interface ProviderTrendItem { | ||
| name: string | ||
| data: Array<[number, number]> | ||
| total_tokens: number | ||
| } | ||
|
|
||
| interface ProviderRankingItem { | ||
| provider_id: string | ||
| tokens: number | ||
| } | ||
|
|
||
| interface UmoRankingItem { | ||
| umo: string | ||
| tokens: number | ||
| } | ||
|
|
||
| interface ProviderTokenStatsResponse { | ||
| days: TokenRange | ||
| trend: { | ||
| series: ProviderTrendItem[] | ||
| total_series: Array<[number, number]> | ||
| } | ||
| range_total_tokens: number | ||
| range_total_calls: number | ||
| range_avg_ttft_ms: number | ||
| range_avg_duration_ms: number | ||
| range_avg_tpm: number | ||
| range_success_rate: number | ||
| range_by_provider: ProviderRankingItem[] | ||
| range_by_umo: UmoRankingItem[] | ||
| today_total_tokens: number | ||
| today_total_calls: number | ||
| today_by_provider: ProviderRankingItem[] | ||
| } | ||
|
|
||
| const { locale } = useI18n() | ||
| const { tm: t } = useModuleI18n('features/stats') | ||
| const theme = useTheme() | ||
| const loading = ref(true) | ||
| const errorMessage = ref('') | ||
| const baseStats = ref<BaseStatsResponse | null>(null) | ||
| const providerStats = ref<ProviderTokenStatsResponse | null>(null) | ||
| const selectedRange = ref<TokenRange>(1) | ||
| const lastUpdatedAt = ref<Date | null>(null) | ||
| const isDark = computed(() => theme.global.current.value.dark) | ||
| const themePalette = computed(() => { | ||
| const colors = theme.global.current.value.colors as Record<string, string> | ||
| return { | ||
| primary: colors.primary, | ||
| secondary: colors.secondary, | ||
| info: colors.info, | ||
| success: colors.success, | ||
| warning: colors.warning, | ||
| accent: colors.accent, | ||
| border: colors.border ?? colors.borderLight ?? colors.primary, | ||
| mutedText: colors.secondaryText ?? colors.primaryText ?? colors.primary, | ||
| lightPrimary: colors.lightprimary ?? colors.surface ?? colors.background, | ||
| lightSecondary: colors.lightsecondary ?? colors.surface ?? colors.background | ||
| } | ||
| }) | ||
|
|
||
| let refreshTimer: number | null = null | ||
|
|
||
| function formatNumber(value: number): string { | ||
| return new Intl.NumberFormat(locale.value).format(value) | ||
| } | ||
|
|
||
| function formatCompactNumber(value: number): string { | ||
| if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B` | ||
| if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M` | ||
| if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K` | ||
| return formatNumber(value) | ||
| } | ||
|
|
||
| function formatMemory(memoryMb: number): string { | ||
| if (memoryMb >= 1024) { | ||
| return `${(memoryMb / 1024).toFixed(1)} ${t('units.gb')}` | ||
| } | ||
| return `${formatNumber(memoryMb)} ${t('units.mb')}` | ||
| } | ||
|
|
||
| function formatDurationMs(value: number): string { | ||
| if (!value || value <= 0) return '—' | ||
| if (value < 1000) return `${Math.round(value)} ${t('units.ms')}` | ||
| return `${(value / 1000).toFixed(2)} ${t('units.secondsShort')}` | ||
| } | ||
|
|
||
| function formatTpm(value: number): string { | ||
| if (!value || value <= 0) return '—' | ||
| return `${value.toFixed(0) } ${t('units.tpm')}` | ||
| } | ||
|
|
||
| function hexToRgba(color: string | undefined, alpha: number): string { | ||
| if (!color) return `rgba(0, 0, 0, ${alpha})` | ||
| if (!color.startsWith('#')) return color | ||
|
|
||
| let hex = color.slice(1) | ||
| if (hex.length === 3) { | ||
| hex = hex | ||
| .split('') | ||
| .map((char) => char + char) | ||
| .join('') | ||
| } | ||
|
|
||
| if (hex.length !== 6) return color | ||
|
|
||
| const red = Number.parseInt(hex.slice(0, 2), 16) | ||
| const green = Number.parseInt(hex.slice(2, 4), 16) | ||
| const blue = Number.parseInt(hex.slice(4, 6), 16) | ||
| return `rgba(${red}, ${green}, ${blue}, ${alpha})` | ||
| } | ||
|
|
||
| function formatDateTime(timestampSec: number): string { | ||
| if (!timestampSec) return '—' | ||
| return new Date(timestampSec * 1000).toLocaleString(locale.value, { | ||
| month: '2-digit', | ||
| day: '2-digit', | ||
| hour: '2-digit', | ||
| minute: '2-digit' | ||
| }) | ||
| } | ||
|
|
||
| function formatRunningTime(running?: RunningStats | null): string { | ||
| if (!running) return '—' | ||
| const parts = [ | ||
| running.hours > 0 ? `${running.hours}${t('units.hoursShort')}` : '', | ||
| running.minutes > 0 || running.hours > 0 ? `${running.minutes}${t('units.minutesShort')}` : '', | ||
| `${running.seconds}${t('units.secondsShort')}` | ||
| ].filter(Boolean) | ||
| return parts.join(' ') | ||
| } | ||
|
|
||
| function aggregateOverflowSeries(series: ProviderTrendItem[]): ProviderTrendItem[] { | ||
| if (series.length <= 5) return series | ||
| const leading = series.slice(0, 4) | ||
| const overflow = series.slice(4) | ||
| const mergedPoints = overflow[0].data.map(([timestamp], index) => { | ||
| const total = overflow.reduce((sum, item) => sum + (item.data[index]?.[1] ?? 0), 0) | ||
| return [timestamp, total] as [number, number] | ||
| }) | ||
| return [ | ||
| ...leading, | ||
| { | ||
| name: t('chart.others'), | ||
| data: mergedPoints, | ||
| total_tokens: overflow.reduce((sum, item) => sum + item.total_tokens, 0) | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| async function fetchBaseStats(): Promise<void> { | ||
| const response = await axios.get('/api/stat/get', { | ||
| params: { | ||
| offset_sec: selectedRange.value * 24 * 60 * 60 | ||
| } | ||
| }) | ||
| baseStats.value = response.data.data | ||
| } | ||
|
|
||
| async function fetchProviderStats(): Promise<void> { | ||
| const response = await axios.get('/api/stat/provider-tokens', { | ||
| params: { | ||
| days: selectedRange.value | ||
| } | ||
| }) | ||
| providerStats.value = response.data.data | ||
| } | ||
|
|
||
| async function refreshStats(): Promise<void> { | ||
| try { | ||
| errorMessage.value = '' | ||
| await Promise.all([fetchBaseStats(), fetchProviderStats()]) | ||
| lastUpdatedAt.value = new Date() | ||
| } catch (error) { | ||
| console.error('Failed to load stats page data:', error) | ||
| errorMessage.value = t('errors.loadFailed') | ||
| } finally { | ||
| loading.value = false | ||
| } | ||
| } | ||
|
|
||
| const rangeOptions = computed(() => [ | ||
| { labelKey: 'ranges.oneDay', value: 1 as TokenRange }, | ||
| { labelKey: 'ranges.threeDays', value: 3 as TokenRange }, | ||
| { labelKey: 'ranges.oneWeek', value: 7 as TokenRange } | ||
| ]) | ||
|
|
||
| const lastUpdatedLabel = computed(() => { | ||
| if (!lastUpdatedAt.value) return t('header.notUpdated') | ||
| return lastUpdatedAt.value.toLocaleTimeString(locale.value, { | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| second: '2-digit' | ||
| }) | ||
| }) | ||
|
|
||
| const rangeLabel = computed(() => { | ||
| if (selectedRange.value === 3) return t('rangeLabels.threeDays') | ||
| if (selectedRange.value === 7) return t('rangeLabels.oneWeek') | ||
| return t('rangeLabels.oneDay') | ||
| }) | ||
|
|
||
| const overviewCards = computed(() => [ | ||
| { | ||
| label: t('overviewCards.platformCount.label'), | ||
| value: formatNumber(baseStats.value?.platform_count ?? 0), | ||
| note: t('overviewCards.platformCount.note'), | ||
| icon: 'mdi-robot-outline' | ||
| }, | ||
| { | ||
| label: t('overviewCards.messageCount.label'), | ||
| value: formatNumber(baseStats.value?.message_count ?? 0), | ||
| note: t('overviewCards.messageCount.note'), | ||
| icon: 'mdi-message-outline' | ||
| }, | ||
| { | ||
| label: t('overviewCards.todayModelCalls.label'), | ||
| value: formatCompactNumber(providerStats.value?.today_total_tokens ?? 0), | ||
| note: t('overviewCards.todayModelCalls.note'), | ||
| icon: 'mdi-creation-outline' | ||
| }, | ||
| { | ||
| label: t('overviewCards.cpu.label'), | ||
| value: `${baseStats.value?.cpu_percent ?? 0}%`, | ||
| note: t('overviewCards.cpu.note'), | ||
| icon: 'mdi-chip' | ||
| }, | ||
| { | ||
| label: t('overviewCards.memory.label'), | ||
| value: formatMemory(baseStats.value?.memory?.process ?? 0), | ||
| note: t('overviewCards.memory.note', { | ||
| systemMemory: formatMemory(baseStats.value?.memory?.system ?? 0) | ||
| }), | ||
| icon: 'mdi-memory' | ||
| }, | ||
| { | ||
| label: t('overviewCards.uptime.label'), | ||
| value: formatRunningTime(baseStats.value?.running), | ||
| note: t('overviewCards.uptime.note', { startTime: startTimeLabel.value }), | ||
| icon: 'mdi-timer-outline' | ||
| } | ||
| ]) | ||
|
|
||
| const messageChartSeries = computed<ChartSeries>(() => [ | ||
| { | ||
| name: t('chart.messages'), | ||
| data: (baseStats.value?.message_time_series ?? []).map(([timestamp, value]) => [ | ||
| timestamp * 1000, | ||
| value | ||
| ]) | ||
| } | ||
| ]) | ||
|
|
||
| const providerTrendSeries = computed<ChartSeries>(() => | ||
| aggregateOverflowSeries(providerStats.value?.trend.series ?? []).map((item) => ({ | ||
| name: item.name, | ||
| data: item.data | ||
| })) | ||
| ) | ||
|
|
||
| const rangeProviderRanking = computed(() => providerStats.value?.range_by_provider ?? []) | ||
|
|
||
| const rangeUmoRanking = computed(() => | ||
| (providerStats.value?.range_by_umo ?? []).slice(0, 10) | ||
| ) | ||
|
|
||
| const rangeAvgTtftLabel = computed(() => | ||
| formatDurationMs(providerStats.value?.range_avg_ttft_ms ?? 0) | ||
| ) | ||
|
|
||
| const rangeAvgDurationLabel = computed(() => | ||
| formatDurationMs(providerStats.value?.range_avg_duration_ms ?? 0) | ||
| ) | ||
|
|
||
| const rangeAvgTpmLabel = computed(() => | ||
| formatTpm(providerStats.value?.range_avg_tpm ?? 0) | ||
| ) | ||
|
|
||
| const rangeSuccessRateLabel = computed(() => { | ||
| if (!(providerStats.value?.range_total_calls ?? 0)) { | ||
| return '—' | ||
| } | ||
| const rate = providerStats.value?.range_success_rate ?? 0 | ||
| return `${(rate * 100).toFixed(1)}%` | ||
| }) | ||
|
|
||
| const platformRanking = computed(() => | ||
| [...(baseStats.value?.platform ?? [])] | ||
| .sort((left, right) => right.count - left.count) | ||
| .slice(0, 6) | ||
| ) | ||
|
|
||
| const startTimeLabel = computed(() => | ||
| formatDateTime(baseStats.value?.start_time ?? 0) | ||
| ) | ||
|
|
||
| const providerChartColors = computed(() => | ||
| isDark.value | ||
| ? [ | ||
| '#6F8FAF', | ||
| '#7E9A73', | ||
| '#A78468', | ||
| '#8A78A8', | ||
| '#6B9995', | ||
| '#B07A87', | ||
| '#8C8F62', | ||
| '#7C8798' | ||
| ] | ||
| : [ | ||
| '#5F7E9B', | ||
| '#708865', | ||
| '#9A7557', | ||
| '#786696', | ||
| '#5D8985', | ||
| '#9C6674', | ||
| '#80844F', | ||
| '#69788D' | ||
| ] | ||
| ) | ||
|
|
||
| const messageChartOptions = computed<ApexOptions>(() => ({ | ||
| chart: { | ||
| background: 'transparent', | ||
| toolbar: { show: false }, | ||
| zoom: { enabled: false }, | ||
| fontFamily: '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | ||
| }, | ||
| theme: { | ||
| mode: isDark.value ? 'dark' : 'light' | ||
| }, | ||
| colors: [themePalette.value.primary], | ||
| stroke: { | ||
| curve: 'smooth', | ||
| width: 2.4 | ||
| }, | ||
| fill: { | ||
| type: 'solid', | ||
| opacity: 0.12 | ||
| }, | ||
| grid: { | ||
| borderColor: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26), | ||
| strokeDashArray: 0 | ||
| }, | ||
| dataLabels: { enabled: false }, | ||
| xaxis: { | ||
| type: 'datetime', | ||
| labels: { | ||
| datetimeUTC: false, | ||
| style: { colors: themePalette.value.mutedText } | ||
| }, | ||
| axisBorder: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) }, | ||
| axisTicks: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) } | ||
| }, | ||
| yaxis: { | ||
| labels: { | ||
| formatter: (value) => formatCompactNumber(Number(value)), | ||
| style: { colors: themePalette.value.mutedText } | ||
| } | ||
| }, | ||
| tooltip: { | ||
| theme: isDark.value ? 'dark' : 'light', | ||
| x: { | ||
| format: 'MM/dd HH:mm' | ||
| } | ||
| }, | ||
| legend: { show: false } | ||
| })) | ||
|
|
||
| const providerChartOptions = computed<ApexOptions>(() => ({ | ||
| chart: { | ||
| background: 'transparent', | ||
| toolbar: { show: false }, | ||
| zoom: { enabled: false }, | ||
| stacked: true, | ||
| fontFamily: '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | ||
| }, | ||
| theme: { | ||
| mode: isDark.value ? 'dark' : 'light' | ||
| }, | ||
| plotOptions: { | ||
| bar: { | ||
| horizontal: false, | ||
| borderRadius: 4, | ||
| columnWidth: '58%' | ||
| } | ||
| }, | ||
| colors: providerChartColors.value, | ||
| dataLabels: { enabled: false }, | ||
| grid: { | ||
| borderColor: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) | ||
| }, | ||
| xaxis: { | ||
| type: 'datetime', | ||
| labels: { | ||
| datetimeUTC: false, | ||
| style: { colors: themePalette.value.mutedText } | ||
| }, | ||
| axisBorder: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) }, | ||
| axisTicks: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) } | ||
| }, | ||
| yaxis: { | ||
| labels: { | ||
| formatter: (value) => formatCompactNumber(Number(value)), | ||
| style: { colors: themePalette.value.mutedText } | ||
| } | ||
| }, | ||
| tooltip: { | ||
| theme: isDark.value ? 'dark' : 'light', | ||
| x: { | ||
| format: 'MM/dd HH:mm' | ||
| } | ||
| }, | ||
| legend: { | ||
| position: 'top', | ||
| horizontalAlign: 'left', | ||
| labels: { | ||
| colors: themePalette.value.mutedText | ||
| } | ||
| } | ||
| })) | ||
|
|
||
| watch(selectedRange, async () => { | ||
| try { | ||
| await Promise.all([fetchBaseStats(), fetchProviderStats()]) | ||
| lastUpdatedAt.value = new Date() | ||
| } catch (error) { | ||
| console.error('Failed to refresh stats range:', error) | ||
| errorMessage.value = t('errors.rangeFailed') | ||
| } | ||
| }) | ||
|
|
||
| onMounted(async () => { | ||
| await refreshStats() | ||
| refreshTimer = window.setInterval(() => { | ||
| void refreshStats() | ||
| }, 60_000) | ||
| }) | ||
|
|
||
| onBeforeUnmount(() => { | ||
| if (refreshTimer !== null) { | ||
| window.clearInterval(refreshTimer) | ||
| } | ||
| }) | ||
| </script> | ||
|
|
||
| <style scoped> | ||
| .stats-page { | ||
| --stats-bg: rgb(var(--v-theme-background)); | ||
| --stats-surface: rgb(var(--v-theme-surface)); | ||
| --stats-text: rgb(var(--v-theme-on-surface)); | ||
| --stats-muted: rgba(var(--v-theme-on-surface), 0.68); | ||
| --stats-subtle: rgba(var(--v-theme-on-surface), 0.56); | ||
| --stats-border: rgba(var(--v-theme-on-surface), 0.1); | ||
| --stats-border-strong: rgba(var(--v-theme-on-surface), 0.14); | ||
| --stats-soft: rgba(var(--v-theme-primary), 0.08); | ||
| --stats-soft-strong: rgba(var(--v-theme-primary), 0.14); | ||
| --stats-shadow: 0 12px 40px rgba(var(--v-theme-on-surface), 0.04); | ||
| min-height: 100%; | ||
| background: var(--stats-bg); | ||
| } | ||
|
|
||
| .stats-page.is-dark { | ||
| --stats-border: rgba(var(--v-theme-on-surface), 0.14); | ||
| --stats-border-strong: rgba(var(--v-theme-on-surface), 0.18); | ||
| --stats-soft: rgba(var(--v-theme-primary), 0.12); | ||
| --stats-soft-strong: rgba(var(--v-theme-primary), 0.2); | ||
| --stats-shadow: none; | ||
| } | ||
|
|
||
| .stats-shell { | ||
| max-width: 1560px; | ||
| margin: 0 auto; | ||
| color: var(--stats-text); | ||
| font-family: "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | ||
| } | ||
|
|
||
| .stats-header { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: flex-end; | ||
| gap: 24px; | ||
| margin-bottom: 24px; | ||
| } | ||
|
|
||
| .eyebrow { | ||
| margin-bottom: 8px; | ||
| color: var(--stats-subtle); | ||
| font-size: 12px; | ||
| font-weight: 600; | ||
| letter-spacing: 0.08em; | ||
| text-transform: uppercase; | ||
| } | ||
|
|
||
| .stats-title { | ||
| margin: 0; | ||
| font-size: clamp(34px, 4vw, 46px); | ||
| line-height: 1.04; | ||
| font-weight: 700; | ||
| letter-spacing: -0.04em; | ||
| } | ||
|
|
||
| .stats-subtitle { | ||
| margin: 10px 0 0; | ||
| color: var(--stats-muted); | ||
| font-size: 15px; | ||
| } | ||
|
|
||
| .stats-page.is-dark .eyebrow, | ||
| .stats-page.is-dark .stats-subtitle, | ||
| .stats-page.is-dark .metric-label, | ||
| .stats-page.is-dark .section-subtitle, | ||
| .stats-page.is-dark .card-note, | ||
| .stats-page.is-dark .empty-state { | ||
| color: var(--stats-muted); | ||
| } | ||
|
|
||
| .header-meta { | ||
| display: flex; | ||
| gap: 12px; | ||
| } | ||
|
|
||
| .meta-pill { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| padding: 10px 14px; | ||
| border: 1px solid var(--stats-border); | ||
| border-radius: 999px; | ||
| background: var(--stats-surface); | ||
| color: var(--stats-muted); | ||
| font-size: 13px; | ||
| } | ||
|
|
||
| .stats-page.is-dark .meta-pill { | ||
| border-color: var(--stats-border-strong); | ||
| background: var(--stats-surface); | ||
| color: var(--stats-muted); | ||
| } | ||
|
|
||
| .loading-wrap { | ||
| display: flex; | ||
| justify-content: center; | ||
| padding: 80px 0; | ||
| } | ||
|
|
||
| .overview-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(6, minmax(0, 1fr)); | ||
| gap: 16px; | ||
| margin-bottom: 20px; | ||
| } | ||
|
|
||
| .panel-grid, | ||
| .token-grid { | ||
| display: grid; | ||
| grid-template-columns: 1.6fr 0.9fr; | ||
| gap: 20px; | ||
| margin-bottom: 20px; | ||
| align-items: stretch; | ||
| } | ||
|
|
||
| .panel-grid > *, | ||
| .token-grid > * { | ||
| min-width: 0; | ||
| width: 100%; | ||
| } | ||
|
|
||
| .token-side-column { | ||
| display: grid; | ||
| grid-template-rows: auto 1fr; | ||
| gap: 20px; | ||
| min-width: 0; | ||
| width: 100%; | ||
| } | ||
|
|
||
| .stat-card { | ||
| border: 1px solid var(--stats-border); | ||
| border-radius: 28px; | ||
| background: var(--stats-surface); | ||
| box-shadow: var(--stats-shadow); | ||
| } | ||
|
|
||
| .stats-page.is-dark .stat-card { | ||
| border-color: var(--stats-border-strong); | ||
| background: var(--stats-surface); | ||
| box-shadow: var(--stats-shadow); | ||
| } | ||
|
|
||
| .overview-card { | ||
| padding: 20px 20px 18px; | ||
| } | ||
|
|
||
| .card-icon { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| width: 34px; | ||
| height: 34px; | ||
| border-radius: 12px; | ||
| background: var(--stats-soft); | ||
| color: rgb(var(--v-theme-primary)); | ||
| } | ||
|
|
||
| .stats-page.is-dark .card-icon { | ||
| background: var(--stats-soft-strong); | ||
| color: rgb(var(--v-theme-primary)); | ||
| } | ||
|
|
||
| .card-label { | ||
| margin-top: 8px; | ||
| color: var(--stats-muted); | ||
| font-size: 13px; | ||
| font-weight: 500; | ||
| } | ||
|
|
||
| .stats-page.is-dark .card-label, | ||
| .stats-page.is-dark .system-row, | ||
| .stats-page.is-dark .system-meta-item, | ||
| .stats-page.is-dark .provider-name { | ||
| color: var(--stats-muted); | ||
| } | ||
|
|
||
| .card-value { | ||
| margin-top: 8px; | ||
| font-size: clamp(24px, 2vw, 34px); | ||
| line-height: 1.1; | ||
| font-weight: 700; | ||
| letter-spacing: -0.03em; | ||
| } | ||
|
|
||
| .card-note { | ||
| margin-top: 8px; | ||
| color: var(--stats-subtle); | ||
| font-size: 12px; | ||
| line-height: 1.5; | ||
| } | ||
|
|
||
| .chart-card, | ||
| .system-card, | ||
| .provider-list-card, | ||
| .token-total-card { | ||
| padding: 22px; | ||
| } | ||
|
|
||
| .card-head { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: flex-start; | ||
| gap: 20px; | ||
| margin-bottom: 18px; | ||
| } | ||
|
|
||
| .section-toolbar { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| gap: 16px; | ||
| align-items: flex-end; | ||
| margin-bottom: 16px; | ||
| } | ||
|
|
||
| .section-toolbar .section-subtitle { | ||
| max-width: 680px; | ||
| } | ||
|
|
||
| .card-head-actions { | ||
| display: flex; | ||
| align-items: flex-start; | ||
| justify-content: flex-end; | ||
| gap: 14px; | ||
| flex-wrap: wrap; | ||
| } | ||
|
|
||
| .card-head.compact { | ||
| margin-bottom: 14px; | ||
| } | ||
|
|
||
| .section-title { | ||
| font-size: 19px; | ||
| font-weight: 650; | ||
| letter-spacing: -0.02em; | ||
| } | ||
|
|
||
| .section-subtitle { | ||
| margin-top: 6px; | ||
| color: var(--stats-muted); | ||
| font-size: 13px; | ||
| } | ||
|
|
||
| .section-metric { | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: flex-end; | ||
| gap: 4px; | ||
| } | ||
|
|
||
| .metric-label { | ||
| color: var(--stats-subtle); | ||
| font-size: 12px; | ||
| } | ||
|
|
||
| .metric-value { | ||
| font-size: 22px; | ||
| font-weight: 650; | ||
| } | ||
|
|
||
| .system-metric + .system-metric { | ||
| margin-top: 18px; | ||
| } | ||
|
|
||
| .system-row, | ||
| .system-meta-item, | ||
| .provider-row { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| gap: 12px; | ||
| } | ||
|
|
||
| .system-row { | ||
| margin-bottom: 10px; | ||
| color: var(--stats-muted); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .system-meta-list { | ||
| margin-top: 20px; | ||
| border-top: 1px solid var(--stats-border); | ||
| padding-top: 14px; | ||
| } | ||
|
|
||
| .stats-page.is-dark .system-meta-list { | ||
| border-top-color: var(--stats-border-strong); | ||
| } | ||
|
|
||
| .system-meta-item { | ||
| padding: 10px 0; | ||
| color: var(--stats-muted); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .system-meta-item + .system-meta-item { | ||
| border-top: 1px solid var(--stats-border); | ||
| } | ||
|
|
||
| .stats-page.is-dark .system-meta-item + .system-meta-item, | ||
| .stats-page.is-dark .provider-row { | ||
| border-color: var(--stats-border-strong); | ||
| } | ||
|
|
||
| .token-section-head { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: flex-end; | ||
| gap: 16px; | ||
| margin-bottom: 16px; | ||
| } | ||
|
|
||
| .range-switch { | ||
| display: inline-flex; | ||
| gap: 8px; | ||
| padding: 6px; | ||
| border: 1px solid var(--stats-border); | ||
| border-radius: 999px; | ||
| background: var(--stats-surface); | ||
| } | ||
|
|
||
| .stats-page.is-dark .range-switch { | ||
| border-color: var(--stats-border-strong); | ||
| background: var(--stats-surface); | ||
| } | ||
|
|
||
| .range-chip { | ||
| border: 0; | ||
| border-radius: 999px; | ||
| background: transparent; | ||
| color: var(--stats-muted); | ||
| padding: 9px 14px; | ||
| font-size: 13px; | ||
| font-weight: 600; | ||
| cursor: pointer; | ||
| transition: background-color 0.18s ease, color 0.18s ease; | ||
| } | ||
|
|
||
| .range-chip.active { | ||
| background: var(--stats-soft); | ||
| color: rgb(var(--v-theme-primary)); | ||
| } | ||
|
|
||
| .stats-page.is-dark .range-chip { | ||
| color: var(--stats-muted); | ||
| } | ||
|
|
||
| .stats-page.is-dark .range-chip.active { | ||
| background: var(--stats-soft-strong); | ||
| color: rgb(var(--v-theme-primary)); | ||
| } | ||
|
|
||
| .token-total-card { | ||
| display: flex; | ||
| flex-direction: column; | ||
| justify-content: center; | ||
| min-height: 170px; | ||
| width: 100%; | ||
| } | ||
|
|
||
| .provider-trend-card { | ||
| min-height: 520px; | ||
| } | ||
|
|
||
| .provider-list-card { | ||
| width: 100%; | ||
| } | ||
|
|
||
| .token-total-value { | ||
| margin-top: 10px; | ||
| font-size: clamp(32px, 3vw, 44px); | ||
| line-height: 1.02; | ||
| font-weight: 700; | ||
| } | ||
|
|
||
| .token-meta-list { | ||
| margin-top: 18px; | ||
| border-top: 1px solid var(--stats-border); | ||
| padding-top: 14px; | ||
| display: grid; | ||
| gap: 10px; | ||
| } | ||
|
|
||
| .token-meta-item { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| gap: 12px; | ||
| color: var(--stats-muted); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .provider-list { | ||
| display: grid; | ||
| gap: 12px; | ||
| } | ||
|
|
||
| .provider-list--scrollable { | ||
| max-height: 296px; | ||
| overflow-y: auto; | ||
| padding-right: 6px; | ||
| } | ||
|
|
||
| .provider-row { | ||
| padding: 12px 0; | ||
| border-bottom: 1px solid var(--stats-border); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .provider-row:last-child { | ||
| border-bottom: 0; | ||
| } | ||
|
|
||
| .provider-name { | ||
| color: var(--stats-text); | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
|
|
||
| .empty-state { | ||
| color: var(--stats-muted); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .empty-state.large { | ||
| padding: 56px 0; | ||
| text-align: center; | ||
| } | ||
|
|
||
| @media (max-width: 1400px) { | ||
| .overview-grid { | ||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | ||
| } | ||
| } | ||
|
|
||
| @media (max-width: 1080px) { | ||
| .panel-grid, | ||
| .token-grid { | ||
| grid-template-columns: 1fr; | ||
| } | ||
| } | ||
|
|
||
| @media (max-width: 900px) { | ||
| .overview-grid { | ||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| } | ||
|
|
||
| .stats-header, | ||
| .token-section-head { | ||
| flex-direction: column; | ||
| align-items: flex-start; | ||
| } | ||
|
|
||
| .section-toolbar { | ||
| justify-content: flex-start; | ||
| align-items: flex-start; | ||
| flex-direction: column; | ||
| } | ||
|
|
||
| .card-head, | ||
| .card-head-actions { | ||
| flex-direction: column; | ||
| align-items: flex-start; | ||
| } | ||
| } | ||
|
|
||
| @media (max-width: 640px) { | ||
| .overview-grid { | ||
| grid-template-columns: 1fr; | ||
| } | ||
|
|
||
| .stats-shell { | ||
| padding-left: 12px !important; | ||
| padding-right: 12px !important; | ||
| } | ||
|
|
||
| .chart-card, | ||
| .system-card, | ||
| .provider-list-card, | ||
| .token-total-card { | ||
| padding: 18px; | ||
| border-radius: 22px; | ||
| } | ||
| } | ||
| </style> |
There was a problem hiding this comment.
This Vue component is very large, containing all the logic for data fetching, state management, formatting, and rendering for the entire page. This can make it difficult to maintain and test.
To improve maintainability, consider breaking it down into smaller, more focused child components. For example:
- A component for the overview cards grid.
- A component for the message trend chart.
- A component for the provider token trend chart.
- Reusable components for the ranking lists.
You could use a global state management solution (like Pinia) or provide/inject to manage the shared state (like baseStats, providerStats, selectedRange) and pass down only the necessary props to each child component.
This refactoring would make the code easier to navigate, understand, and test.
|
Documentation Updates 1 document(s) were updated by changes in this PR: pr4697的改动View Changes@@ -657,6 +657,63 @@
- 后台任务需正确设置 `background_task: true` 参数
- 后台任务执行通过 `HandoffExecutor.execute_queued_task()` 方法完成,该方法从数据库中恢复任务上下文并执行
+#### 提供商统计数据自动收集(PR #7152)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 引入了提供商统计数据的自动收集机制,用于追踪内置 Agent 执行期间的令牌使用量、性能指标和 API 调用状态。
+
+**数据库模型(ProviderStat 表):**
+
+新增 `ProviderStat` 表,用于存储每次内置 Agent 执行的提供商统计数据,包含以下字段:
+
+- **`agent_type`**:Agent 类型(默认为 "internal"),索引字段
+- **`status`**:请求状态(如 "completed"、"error"、"aborted"),索引字段
+- **`umo`**:统一消息来源(Unified Message Origin)标识符,索引字段
+- **`conversation_id`**:关联的对话 ID,可选,索引字段
+- **`provider_id`**:提供商标识符(如 "openai"、"anthropic"),索引字段
+- **`provider_model`**:使用的模型名称(如 "gpt-4o"、"claude-3-opus"),可选,索引字段
+- **`token_input_other`**:非缓存输入令牌数(默认 0)
+- **`token_input_cached`**:缓存输入令牌数(默认 0)
+- **`token_output`**:输出令牌数(默认 0)
+- **`start_time`**:请求开始时间戳(秒,默认 0.0)
+- **`end_time`**:请求结束时间戳(秒,默认 0.0)
+- **`time_to_first_token`**:首个令牌响应时间(秒,默认 0.0)
+
+**自动统计收集:**
+
+在内置 Agent 执行完成后,系统会自动调用 `_record_internal_agent_stats()` 函数(位于 `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py`),将统计数据持久化到数据库:
+
+- **采集时机**:Agent 执行完成后,通过 `asyncio.create_task()` 异步记录统计数据,不阻塞用户响应
+- **状态判断**:
+ - 任务被用户中止:`status="aborted"`
+ - LLM 返回错误:`status="error"`
+ - 正常完成:`status="completed"`
+- **数据来源**:从 `AgentRunner.stats` 对象中提取令牌使用量和性能指标
+- **错误容错**:统计记录失败时记录警告日志,不影响用户对话流程
+
+**数据库操作方法:**
+
+新增 `insert_provider_stat()` 方法(位于 `astrbot/core/db/sqlite.py`),用于插入提供商统计记录:
+
+```python
+await db_helper.insert_provider_stat(
+ umo=event.unified_msg_origin,
+ conversation_id=conversation_id,
+ provider_id=provider_config.get("id", "") or provider.meta().id,
+ provider_model=provider.get_model(),
+ status=status,
+ stats=stats.to_dict(),
+ agent_type="internal",
+)
+```
+
+该方法自动从 `stats` 字典中提取 `token_usage`、`start_time`、`end_time` 和 `time_to_first_token` 字段,并转换为数值类型存储。
+
+**数据用途:**
+
+- 支持统计数据页面(StatsPage.vue)展示令牌消耗趋势和模型使用统计
+- 通过 `/api/stat/provider-tokens` API 端点查询和聚合统计数据
+- 为用户提供详细的 API 调用成本分析和性能监控能力
+
#### Neo 技能生命周期工具(PR #5028)
> ⚠️ **REVERTED**: 本节描述的所有 Neo 技能生命周期管理工具和功能已在 [PR #5624](https://github.com/AstrBotDevs/AstrBot/pull/5624) 中被完全移除,以下内容仅供历史参考。
@@ -721,6 +778,27 @@
主代理和子代理的工具分配逻辑在 UI 中有清晰展示,支持灵活配置。
+#### 统计数据页面(StatsPage.vue)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 引入了全新的统计数据页面(StatsPage.vue),提供系统活动和模型使用的全面概览。该页面现已替代旧的 DefaultDashboard.vue,并在路由中作为默认仪表板页面(`/dashboard/default`)。
+
+**核心功能:**
+
+- **消息概览与趋势**:展示消息量的时间序列变化和统计数据
+- **模型调用统计与趋势**:显示不同 LLM 提供商的 API 调用次数和趋势
+- **令牌消耗趋势(按提供商)**:按提供商分类显示令牌使用量的时间序列数据
+- **会话排名**:列出最活跃的会话和用户
+- **平台统计**:展示不同平台(QQ、Telegram、Discord 等)的消息分布
+- **内存使用监控**:实时显示系统内存占用情况
+- **国际化支持**:完整支持中文(zh-CN)、英文(en-US)和俄语(ru-RU)
+
+**技术实现:**
+
+- 时间范围选择器支持 1 天、3 天和 7 天的数据查询
+- 交互式图表展示趋势数据(基于时间序列的折线图)
+- 多指标仪表板卡片展示关键性能指标(KPI)
+- 响应式设计适配不同屏幕尺寸
+
#### 定时任务管理
新增 Cron Job 管理页面(CronJobPage.vue),支持:
@@ -729,6 +807,86 @@
#### API 支持
新增 CronRoute 等 API 路由,支持通过 API 管理定时任务。
+
+##### 提供商令牌统计 API(`/stat/provider-tokens`)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 新增了提供商令牌统计 API 端点,为统计数据页面提供详细的模型使用数据。
+
+**端点信息:**
+- **路径**:`GET /api/stat/provider-tokens`
+- **查询参数**:
+ - `days`:查询天数,可选值为 1、3 或 7,默认值为 1
+
+**返回数据结构:**
+
+```json
+{
+ "days": 1,
+ "trend": {
+ "series": [
+ {
+ "name": "provider_id",
+ "data": [[timestamp_ms, tokens], ...],
+ "total_tokens": 12345
+ }
+ ],
+ "total_series": [[timestamp_ms, total_tokens], ...]
+ },
+ "range_total_tokens": 12345,
+ "range_total_calls": 100,
+ "range_avg_ttft_ms": 123.45,
+ "range_avg_duration_ms": 456.78,
+ "range_avg_tpm": 789.12,
+ "range_success_rate": 0.95,
+ "range_by_provider": [
+ {"provider_id": "openai", "tokens": 5000},
+ {"provider_id": "anthropic", "tokens": 3000}
+ ],
+ "range_by_umo": [
+ {"umo": "user123", "tokens": 4000},
+ {"umo": "user456", "tokens": 2000}
+ ],
+ "today_total_tokens": 6789,
+ "today_total_calls": 50,
+ "today_by_model": [
+ {"provider_model": "gpt-4o", "tokens": 3000},
+ {"provider_model": "claude-3-opus", "tokens": 2000}
+ ],
+ "today_by_provider": [
+ {"provider_id": "openai", "tokens": 3500},
+ {"provider_id": "anthropic", "tokens": 2000}
+ ]
+}
+```
+
+**数据字段说明:**
+
+- **趋势数据(trend)**:
+ - `series`:按提供商分类的时间序列数据,每个数据点为 `[时间戳(毫秒), 令牌数]`
+ - `total_series`:所有提供商汇总的时间序列数据
+
+- **时间范围统计(range_*)**:
+ - `range_total_tokens`:选定时间范围内的总令牌消耗
+ - `range_total_calls`:选定时间范围内的总 API 调用次数
+ - `range_avg_ttft_ms`:首个令牌平均响应时间(毫秒)
+ - `range_avg_duration_ms`:请求平均持续时间(毫秒)
+ - `range_avg_tpm`:平均每分钟令牌数(TPM)
+ - `range_success_rate`:API 调用成功率(0-1 之间)
+ - `range_by_provider`:按提供商分类的令牌消耗排名
+ - `range_by_umo`:按统一消息来源(UMO)分类的令牌消耗排名
+
+- **今日统计(today_*)**:
+ - `today_total_tokens`:今日总令牌消耗
+ - `today_total_calls`:今日总 API 调用次数
+ - `today_by_model`:今日按模型分类的令牌消耗排名
+ - `today_by_provider`:今日按提供商分类的令牌消耗排名
+
+**技术实现要点:**
+
+- 数据按小时聚合(hourly buckets),提供精细的时间趋势
+- 自动处理时区转换,确保本地时间和 UTC 时间的正确映射
+- 支持空数据场景,当无数据时返回零值而非错误
+- 所有排名列表按令牌消耗降序排列
#### Neo 技能管理 UI(PR #5028)
|
Modifications / 改动点
Screenshots or Test Results / 运行截图或测试结果
Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Add backend provider token statistics collection and expose them via a new API, then replace the old default dashboard with a new Stats page that visualizes system and model usage metrics.
New Features:
Enhancements:
Tests: