feat: add brand-new statistics dashboarde overview with model usage and platform usage (#7152)

* feat: add new StatsPage for enhanced statistics overview

- 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.

* style: refine card styles and remove unnecessary shadow for improved aesthetics
This commit is contained in:
Soulter
2026-03-29 22:50:15 +08:00
committed by GitHub
parent 9896b48c5e
commit 2b435e0c89
23 changed files with 1921 additions and 1523 deletions
+16
View File
@@ -21,6 +21,7 @@ from astrbot.core.db.po import (
PlatformSession,
PlatformStat,
Preference,
ProviderStat,
SessionProjectRelation,
Stats,
)
@@ -105,6 +106,21 @@ class BaseDatabase(abc.ABC):
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
...
@abc.abstractmethod
async def insert_provider_stat(
self,
*,
umo: str,
provider_id: str,
provider_model: str | None = None,
conversation_id: str | None = None,
status: str = "completed",
stats: dict | None = None,
agent_type: str = "internal",
) -> ProviderStat:
"""Insert a per-response provider stat record."""
...
@abc.abstractmethod
async def get_conversations(
self,
+24
View File
@@ -38,6 +38,30 @@ class PlatformStat(SQLModel, table=True):
)
class ProviderStat(TimestampMixin, SQLModel, table=True):
"""Per-response provider stats for internal agent runs."""
__tablename__: str = "provider_stats"
id: int | None = Field(
default=None,
primary_key=True,
sa_column_kwargs={"autoincrement": True},
)
agent_type: str = Field(default="internal", nullable=False, index=True)
status: str = Field(default="completed", nullable=False, index=True)
umo: str = Field(nullable=False, index=True)
conversation_id: str | None = Field(default=None, index=True)
provider_id: str = Field(nullable=False, index=True)
provider_model: str | None = Field(default=None, index=True)
token_input_other: int = Field(default=0, nullable=False)
token_input_cached: int = Field(default=0, nullable=False)
token_output: int = Field(default=0, nullable=False)
start_time: float = Field(default=0.0, nullable=False)
end_time: float = Field(default=0.0, nullable=False)
time_to_first_token: float = Field(default=0.0, nullable=False)
class ConversationV2(TimestampMixin, SQLModel, table=True):
__tablename__: str = "conversations"
+46
View File
@@ -23,6 +23,7 @@ from astrbot.core.db.po import (
PlatformSession,
PlatformStat,
Preference,
ProviderStat,
SessionProjectRelation,
SQLModel,
)
@@ -169,6 +170,51 @@ class SQLiteDatabase(BaseDatabase):
)
return list(result.scalars().all())
async def insert_provider_stat(
self,
*,
umo: str,
provider_id: str,
provider_model: str | None = None,
conversation_id: str | None = None,
status: str = "completed",
stats: dict | None = None,
agent_type: str = "internal",
) -> ProviderStat:
"""Insert a provider stat record for a single agent response."""
stats = stats or {}
token_usage = stats.get("token_usage", {})
token_input_other = int(token_usage.get("input_other", 0) or 0)
token_input_cached = int(token_usage.get("input_cached", 0) or 0)
token_output = int(token_usage.get("output", 0) or 0)
start_time = float(stats.get("start_time", 0.0) or 0.0)
end_time = float(stats.get("end_time", 0.0) or 0.0)
time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0)
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
record = ProviderStat(
agent_type=agent_type,
status=status,
umo=umo,
conversation_id=conversation_id,
provider_id=provider_id,
provider_model=provider_model,
token_input_other=token_input_other,
token_input_cached=token_input_cached,
token_output=token_output,
start_time=start_time,
end_time=end_time,
time_to_first_token=time_to_first_token,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
# ====
# Conversation Management
# ====
@@ -5,7 +5,7 @@ import base64
from collections.abc import AsyncGenerator
from dataclasses import replace
from astrbot.core import logger
from astrbot.core import db_helper, logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.response import AgentStats
from astrbot.core.astr_main_agent import (
@@ -350,6 +350,15 @@ class InternalAgentSubStage(Stage):
resp=final_resp.completion_text if final_resp else None,
)
asyncio.create_task(
_record_internal_agent_stats(
event,
req,
agent_runner,
final_resp,
)
)
# 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped() or agent_runner.was_aborted():
await self._save_to_history(
@@ -462,3 +471,46 @@ class InternalAgentSubStage(Stage):
# these hosts are base64 encoded
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
async def _record_internal_agent_stats(
event: AstrMessageEvent,
req: ProviderRequest | None,
agent_runner: AgentRunner | None,
final_resp: LLMResponse | None,
) -> None:
"""Persist internal agent stats without affecting the user response flow."""
if agent_runner is None:
return
provider = agent_runner.provider
stats = agent_runner.stats
if provider is None or stats is None:
return
try:
provider_config = getattr(provider, "provider_config", {}) or {}
conversation_id = (
req.conversation.cid
if req is not None and req.conversation is not None
else None
)
if agent_runner.was_aborted():
status = "aborted"
elif final_resp is not None and final_resp.role == "err":
status = "error"
else:
status = "completed"
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",
)
except Exception as e:
logger.warning("Persist provider stats failed: %s", e, exc_info=True)
+206
View File
@@ -4,18 +4,22 @@ import re
import threading
import time
import traceback
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from functools import cmp_to_key
from pathlib import Path
import aiohttp
import psutil
from quart import request
from sqlmodel import select
from astrbot.core import DEMO_MODE, logger
from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.db.po import ProviderStat
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.storage_cleaner import StorageCleaner
@@ -34,6 +38,7 @@ class StatRoute(Route):
super().__init__(context)
self.routes = {
"/stat/get": ("GET", self.get_stat),
"/stat/provider-tokens": ("GET", self.get_provider_token_stats),
"/stat/version": ("GET", self.get_version),
"/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core),
@@ -188,6 +193,207 @@ class StatRoute(Route):
logger.error(traceback.format_exc())
return Response().error(e.__str__()).__dict__
@staticmethod
def _ensure_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
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__
async def test_ghproxy_connection(self):
"""测试 GitHub 代理连接是否可用。"""
try:
@@ -1,4 +1,4 @@
/* Auto-generated MDI subset – 248 icons */
/* Auto-generated MDI subset – 252 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -136,10 +136,6 @@
content: "\F00F3";
}
.mdi-calendar-range::before {
content: "\F0679";
}
.mdi-chat::before {
content: "\F0B79";
}
@@ -204,6 +200,10 @@
content: "\F0143";
}
.mdi-chip::before {
content: "\F061A";
}
.mdi-circle::before {
content: "\F0765";
}
@@ -288,6 +288,10 @@
content: "\F0674";
}
.mdi-creation-outline::before {
content: "\F1C2B";
}
.mdi-cursor-default-click::before {
content: "\F0CFD";
}
@@ -640,6 +644,10 @@
content: "\F164E";
}
.mdi-message-outline::before {
content: "\F0365";
}
.mdi-message-text::before {
content: "\F0369";
}
@@ -816,6 +824,10 @@
content: "\F16A7";
}
.mdi-robot-outline::before {
content: "\F167A";
}
.mdi-send::before {
content: "\F048A";
}
@@ -932,6 +944,10 @@
content: "\F0BD4";
}
.mdi-timer-outline::before {
content: "\F051B";
}
.mdi-tools::before {
content: "\F1064";
}
@@ -0,0 +1,104 @@
{
"header": {
"eyebrow": "Dashboard",
"title": "System Statistics",
"subtitle": "A unified view of platforms, messages, and model calls.",
"notUpdated": "Not updated yet"
},
"ranges": {
"oneDay": "1 Day",
"threeDays": "3 Days",
"oneWeek": "1 Week"
},
"rangeLabels": {
"oneDay": "Last 1 day",
"threeDays": "Last 3 days",
"oneWeek": "Last 1 week"
},
"overviewCards": {
"platformCount": {
"label": "Platform Instances",
"note": "Configured and currently running platform instances"
},
"messageCount": {
"label": "Messages",
"note": "Total accumulated messages"
},
"todayModelCalls": {
"label": "Today's Model Calls",
"note": "Tokens"
},
"cpu": {
"label": "CPU",
"note": "AstrBot CPU usage"
},
"memory": {
"label": "Process Memory",
"note": "System memory {systemMemory}"
},
"uptime": {
"label": "Uptime",
"note": "Started at {startTime}"
}
},
"messageOverview": {
"title": "Message Overview",
"subtitle": "Robot message statistics"
},
"messageTrend": {
"title": "Message Trend",
"subtitle": "{range}, aggregated by hour",
"totalMessages": "Total Messages"
},
"platformRanking": {
"title": "Platform Message Ranking",
"subtitle": "{range}, sorted by aggregated message volume"
},
"modelCalls": {
"title": "Model Calls",
"subtitle": "Model call statistics"
},
"modelTrend": {
"title": "Model Call Trend",
"subtitle": "Token trend for model calls"
},
"modelTotal": {
"title": "{range} total model calls",
"callCount": "{count} calls",
"avgTtft": "Average TTFT",
"avgDuration": "Average Response Time",
"avgTpm": "Average TPM",
"successRate": "Success Rate"
},
"modelRanking": {
"title": "{range} model call ranking",
"subtitle": "Ranked by model call tokens"
},
"sessionRanking": {
"title": "{range} session token top 10",
"subtitle": "Sessions ranked by token usage"
},
"empty": {
"platformStats": "No platform message statistics available.",
"modelCalls": "No model call data for {range}.",
"sessionCalls": "No session call data for {range}."
},
"chart": {
"messages": "Messages",
"others": "Others"
},
"units": {
"tokens": "Tokens",
"tpm": "TPM",
"gb": "GB",
"mb": "MB",
"ms": "ms",
"hoursShort": "h",
"minutesShort": "m",
"secondsShort": "s"
},
"errors": {
"loadFailed": "Failed to load statistics. Please try again later.",
"rangeFailed": "Failed to switch the statistics range. Please try again later."
}
}
@@ -0,0 +1,104 @@
{
"header": {
"eyebrow": "Dashboard",
"title": "Статистика системы",
"subtitle": "Единый обзор платформ, сообщений и вызовов моделей.",
"notUpdated": "Еще не обновлялось"
},
"ranges": {
"oneDay": "1 день",
"threeDays": "3 дня",
"oneWeek": "1 неделя"
},
"rangeLabels": {
"oneDay": "За 1 день",
"threeDays": "За 3 дня",
"oneWeek": "За 1 неделю"
},
"overviewCards": {
"platformCount": {
"label": "Платформы",
"note": "Настроенные и запущенные экземпляры платформ"
},
"messageCount": {
"label": "Сообщения",
"note": "Общее число сообщений"
},
"todayModelCalls": {
"label": "Вызовы моделей сегодня",
"note": "Tokens"
},
"cpu": {
"label": "CPU",
"note": "Использование CPU AstrBot"
},
"memory": {
"label": "Память процесса",
"note": "Системная память {systemMemory}"
},
"uptime": {
"label": "Время работы",
"note": "Запущен в {startTime}"
}
},
"messageOverview": {
"title": "Обзор сообщений",
"subtitle": "Статистика сообщений бота"
},
"messageTrend": {
"title": "Динамика сообщений",
"subtitle": "{range}, агрегация по часам",
"totalMessages": "Всего сообщений"
},
"platformRanking": {
"title": "Рейтинг платформ по сообщениям",
"subtitle": "{range}, сортировка по общему числу сообщений"
},
"modelCalls": {
"title": "Вызовы моделей",
"subtitle": "Статистика вызовов моделей"
},
"modelTrend": {
"title": "Динамика вызовов моделей",
"subtitle": "Динамика токенов вызовов моделей"
},
"modelTotal": {
"title": "{range} всего вызовов моделей",
"callCount": "{count} вызовов",
"avgTtft": "Средний TTFT",
"avgDuration": "Среднее время ответа",
"avgTpm": "Средний TPM",
"successRate": "Доля успешных вызовов"
},
"modelRanking": {
"title": "{range} рейтинг вызовов моделей",
"subtitle": "Рейтинг по токенам вызовов моделей"
},
"sessionRanking": {
"title": "{range} топ-10 сессий по токенам",
"subtitle": "Сессии с наибольшим расходом токенов"
},
"empty": {
"platformStats": "Статистика сообщений по платформам отсутствует.",
"modelCalls": "Нет данных по вызовам моделей за период {range}.",
"sessionCalls": "Нет данных по сессиям за период {range}."
},
"chart": {
"messages": "Сообщения",
"others": "Другое"
},
"units": {
"tokens": "Tokens",
"tpm": "TPM",
"gb": "ГБ",
"mb": "МБ",
"ms": "мс",
"hoursShort": "ч",
"minutesShort": "м",
"secondsShort": "с"
},
"errors": {
"loadFailed": "Не удалось загрузить статистику. Повторите попытку позже.",
"rangeFailed": "Не удалось переключить диапазон статистики. Повторите попытку позже."
}
}
@@ -0,0 +1,104 @@
{
"header": {
"eyebrow": "Dashboard",
"title": "系统统计",
"subtitle": "平台、消息与模型调用的统一视图。",
"notUpdated": "尚未更新"
},
"ranges": {
"oneDay": "1 天",
"threeDays": "3 天",
"oneWeek": "1 周"
},
"rangeLabels": {
"oneDay": "最近 1 天",
"threeDays": "最近 3 天",
"oneWeek": "最近 1 周"
},
"overviewCards": {
"platformCount": {
"label": "平台实例数",
"note": "当前配置并已运行的平台实例"
},
"messageCount": {
"label": "消息总数",
"note": "累计消息量"
},
"todayModelCalls": {
"label": "今日模型调用",
"note": "词元(Tokens)"
},
"cpu": {
"label": "CPU",
"note": "程序 CPU 占用"
},
"memory": {
"label": "进程内存",
"note": "系统内存 {systemMemory}"
},
"uptime": {
"label": "运行时间",
"note": "启动于 {startTime}"
}
},
"messageOverview": {
"title": "消息概览",
"subtitle": "机器人消息数据统计"
},
"messageTrend": {
"title": "消息趋势",
"subtitle": "{range}按小时聚合",
"totalMessages": "总消息数"
},
"platformRanking": {
"title": "平台消息排名",
"subtitle": "{range}按聚合消息总量排序"
},
"modelCalls": {
"title": "模型调用",
"subtitle": "模型调用数据统计"
},
"modelTrend": {
"title": "模型调用趋势",
"subtitle": "模型调用词元(Tokens)趋势统计"
},
"modelTotal": {
"title": "{range}模型调用总量",
"callCount": "共 {count} 次调用",
"avgTtft": "平均首字延迟(TTFT)",
"avgDuration": "平均响应时间",
"avgTpm": "平均每分钟词元数(TPM)",
"successRate": "调用成功率"
},
"modelRanking": {
"title": "{range}模型调用排名",
"subtitle": "模型调用词元(Tokens)排名"
},
"sessionRanking": {
"title": "{range}会话词元(Tokens)Top 10",
"subtitle": "词元(Tokens)用量最大的会话排名"
},
"empty": {
"platformStats": "当前没有平台消息统计数据。",
"modelCalls": "{range}还没有模型调用数据。",
"sessionCalls": "{range}还没有会话调用数据。"
},
"chart": {
"messages": "消息",
"others": "其他"
},
"units": {
"tokens": "词元(Tokens)",
"tpm": "TPM",
"gb": "GB",
"mb": "MB",
"ms": "ms",
"hoursShort": "h",
"minutesShort": "m",
"secondsShort": "s"
},
"errors": {
"loadFailed": "统计数据加载失败,请稍后重试。",
"rangeFailed": "统计范围切换失败,请稍后重试。"
}
}
+6
View File
@@ -26,6 +26,7 @@ import zhCNAuth from './locales/zh-CN/features/auth.json';
import zhCNChart from './locales/zh-CN/features/chart.json';
import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
import zhCNCron from './locales/zh-CN/features/cron.json';
import zhCNStats from './locales/zh-CN/features/stats.json';
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
@@ -67,6 +68,7 @@ import enUSAuth from './locales/en-US/features/auth.json';
import enUSChart from './locales/en-US/features/chart.json';
import enUSDashboard from './locales/en-US/features/dashboard.json';
import enUSCron from './locales/en-US/features/cron.json';
import enUSStats from './locales/en-US/features/stats.json';
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
@@ -108,6 +110,7 @@ import ruRUAuth from './locales/ru-RU/features/auth.json';
import ruRUChart from './locales/ru-RU/features/chart.json';
import ruRUDashboard from './locales/ru-RU/features/dashboard.json';
import ruRUCron from './locales/ru-RU/features/cron.json';
import ruRUStats from './locales/ru-RU/features/stats.json';
import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json';
import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json';
import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json';
@@ -153,6 +156,7 @@ export const translations = {
chart: zhCNChart,
dashboard: zhCNDashboard,
cron: zhCNCron,
stats: zhCNStats,
alkaid: {
index: zhCNAlkaidIndex,
'knowledge-base': zhCNAlkaidKnowledgeBase,
@@ -202,6 +206,7 @@ export const translations = {
chart: enUSChart,
dashboard: enUSDashboard,
cron: enUSCron,
stats: enUSStats,
alkaid: {
index: enUSAlkaidIndex,
'knowledge-base': enUSAlkaidKnowledgeBase,
@@ -251,6 +256,7 @@ export const translations = {
chart: ruRUChart,
dashboard: ruRUDashboard,
cron: ruRUCron,
stats: ruRUStats,
alkaid: {
index: ruRUAlkaidIndex,
'knowledge-base': ruRUAlkaidKnowledgeBase,
+2 -2
View File
@@ -52,9 +52,9 @@ const MainRoutes = {
redirect: '/config#system'
},
{
name: 'Default',
name: 'Stats',
path: '/dashboard/default',
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
component: () => import('@/views/stats/StatsPage.vue')
},
{
name: 'Conversation',
@@ -1,223 +0,0 @@
<template>
<div class="dashboard-container">
<v-slide-y-transition>
<v-row v-if="noticeTitle && noticeContent" class="notice-row">
<v-alert
:type="noticeType"
:text="noticeContent"
:title="noticeTitle"
closable
class="dashboard-alert"
variant="tonal"
border="start"
></v-alert>
</v-row>
</v-slide-y-transition>
<!-- 主指标卡片行 -->
<v-row class="stats-row">
<v-col cols="12" md="3">
<v-slide-y-transition>
<TotalMessage :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<OnlinePlatform :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<RunningTime :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<MemoryUsage :stat="stat" />
</v-slide-y-transition>
</v-col>
</v-row>
<!-- 图表行 -->
<v-row class="charts-row">
<v-col cols="12" lg="8">
<v-slide-y-transition>
<MessageStat />
</v-slide-y-transition>
</v-col>
<v-col cols="12" lg="4">
<v-slide-y-transition>
<PlatformStat :stat="stat" />
</v-slide-y-transition>
</v-col>
</v-row>
<div class="dashboard-footer">
<v-chip size="small" color="primary" variant="flat" prepend-icon="mdi-refresh">
{{ t('lastUpdate') }}: {{ lastUpdated }}
</v-chip>
<v-btn
icon="mdi-refresh"
size="small"
color="primary"
variant="text"
class="ml-2"
@click="fetchData"
:loading="isRefreshing"
></v-btn>
</div>
</div>
</template>
<script>
import TotalMessage from './components/TotalMessage.vue';
import OnlinePlatform from './components/OnlinePlatform.vue';
import RunningTime from './components/RunningTime.vue';
import MemoryUsage from './components/MemoryUsage.vue';
import MessageStat from './components/MessageStat.vue';
import PlatformStat from './components/PlatformStat.vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'DefaultDashboard',
components: {
TotalMessage,
OnlinePlatform,
RunningTime,
MemoryUsage,
MessageStat,
PlatformStat,
},
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
data() {
return {
stat: {},
noticeTitle: '',
noticeContent: '',
noticeType: '',
lastUpdated: '',
refreshInterval: null,
isRefreshing: false
};
},
mounted() {
this.lastUpdated = this.t('status.loading');
this.fetchData();
this.fetchNotice();
// 设置自动刷新(每60秒)
this.refreshInterval = setInterval(() => {
this.fetchData();
}, 60000);
},
beforeUnmount() {
// 清除定时器
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
async fetchData() {
this.isRefreshing = true;
try {
const res = await axios.get('/api/stat/get');
this.stat = res.data.data;
this.lastUpdated = new Date().toLocaleTimeString();
console.log('Dashboard data:', this.stat);
} catch (error) {
console.error(this.t('status.dataError'), error);
} finally {
this.isRefreshing = false;
}
},
fetchNotice() {
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
let data = res.data.data;
// 如果 dashboard-notice 在其中
if (data['dashboard-notice']) {
this.noticeTitle = data['dashboard-notice'].title;
this.noticeContent = data['dashboard-notice'].content;
this.noticeType = data['dashboard-notice'].type;
}
}).catch(error => {
console.error(this.t('status.noticeError'), error);
});
}
}
};
</script>
<style scoped>
.dashboard-container {
padding: 16px;
background-color: var(--v-theme-background);
min-height: calc(100vh - 64px);
border-radius: 10px;
}
.notice-row {
margin-bottom: 16px;
}
.dashboard-alert {
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
}
.stats-row, .charts-row, .plugin-row {
margin-bottom: 24px;
}
.plugin-card {
border-radius: 8px;
background-color: var(--v-theme-surface);
}
.plugin-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.plugin-subtitle {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.plugin-item {
transition: transform 0.2s, box-shadow 0.2s;
}
.plugin-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
}
.plugin-name {
font-size: 14px;
font-weight: 500;
}
.plugin-version {
font-size: 12px;
color: var(--v-theme-secondaryText, #666);
}
.dashboard-footer {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
</style>
@@ -1,147 +0,0 @@
<template>
<v-card elevation="1" class="stat-card memory-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-memory" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ t('stats.memoryUsage.title') }}</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB / {{ stat.memory?.system || 0 }} MiB</span></h2>
<v-chip :color="memoryStatus.color" size="x-small" class="status-chip">
{{ memoryStatus.label }}
</v-chip>
</div>
</div>
</div>
<div class="metrics-container">
<div class="metric-item">
<div class="metric-label">{{ t('stats.memoryUsage.cpuLoad') }}</div>
<div class="metric-value">{{ stat.cpu_percent || '0' }}%</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'MemoryUsage',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
computed: {
memoryPercentage() {
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
},
memoryStatus() {
const percentage = this.memoryPercentage;
if (percentage < 30) {
return { color: 'success', label: this.t('stats.memoryUsage.status.good') };
} else if (percentage < 70) {
return { color: 'warning', label: this.t('stats.memoryUsage.status.normal') };
} else {
return { color: 'error', label: this.t('stats.memoryUsage.status.high') };
}
}
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.memory-card {
background-color: #ff9800;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.memory-unit {
font-size: 14px;
font-weight: 400;
opacity: 0.8;
}
.status-chip {
font-weight: 500;
}
.metrics-container {
display: flex;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
margin-top: 4px;
justify-content: center;
}
.metric-item {
flex: 1;
text-align: center;
}
.metric-label {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.metric-value {
font-size: 14px;
font-weight: 600;
}
</style>
@@ -1,391 +0,0 @@
<template>
<v-card elevation="1" class="chart-card">
<v-card-text>
<div class="chart-header">
<div>
<div class="chart-title">{{ t('charts.messageTrend.title') }}</div>
<div class="chart-subtitle">{{ t('charts.messageTrend.subtitle') }}</div>
</div>
<v-select
color="primary"
variant="outlined"
density="compact"
hide-details
v-model="selectedTimeRange"
:items="timeRanges"
item-title="label"
item-value="value"
class="time-select"
@update:model-value="fetchMessageSeries"
return-object
single-line
>
<template v-slot:selection="{ item }">
<div class="d-flex align-center">
<v-icon start size="small">mdi-calendar-range</v-icon>
{{ item.raw.label }}
</div>
</template>
</v-select>
</div>
<div class="chart-stats">
<div class="stat-box">
<div class="stat-label">{{ t('charts.messageTrend.totalMessages') }}</div>
<div class="stat-number">{{ totalMessages }}</div>
</div>
<div class="stat-box">
<div class="stat-label">{{ t('charts.messageTrend.dailyAverage') }}</div>
<div class="stat-number">{{ dailyAverage }}</div>
</div>
<div class="stat-box" :class="{'trend-up': growthRate > 0, 'trend-down': growthRate < 0}">
<div class="stat-label">{{ t('charts.messageTrend.growthRate') }}</div>
<div class="stat-number">
<v-icon v-show="growthRate !== 0" size="small" :icon="growthRate > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'"></v-icon>
{{ Math.abs(growthRate) }}%
</div>
</div>
</div>
<div class="chart-container">
<div v-if="loading" class="loading-overlay">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div class="loading-text">{{ t('status.loading') }}</div>
</div>
<apexchart
type="area"
height="280"
:options="chartOptions"
:series="chartSeries"
ref="chart"
></apexchart>
</div>
</v-card-text>
</v-card>
</template>
<script>
import axios from 'axios';
import {useCustomizerStore} from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'MessageStat',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
data() {
return {
totalMessages: '0',
dailyAverage: '0',
growthRate: 0,
loading: false,
selectedTimeRange: null,
timeRanges: [],
chartOptions: {
chart: {
type: 'area',
height: 400,
fontFamily: `inherit`,
foreColor: '#a1aab2',
toolbar: {
show: true,
tools: {
download: true,
selection: false,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
},
},
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
},
},
colors: ['#5e35b1'],
fill: {
type: 'solid',
opacity: 0.3,
},
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 2
},
markers: {
size: 3,
strokeWidth: 2,
hover: {
size: 5,
}
},
tooltip: {
theme: useCustomizerStore().uiTheme==='PurpleTheme' ? 'light' : 'dark',
x: {
format: 'yyyy-MM-dd HH:mm'
},
y: {
title: {
formatter: () => ''
}
},
},
xaxis: {
type: 'datetime',
title: {
text: ''
},
labels: {
formatter: function (value) {
return new Date(value).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
},
tooltip: {
enabled: false
}
},
yaxis: {
title: {
text: ''
},
min: function(min) {
return min < 10 ? 0 : Math.floor(min * 0.8);
},
},
grid: {
borderColor: "gray100",
row: {
colors: ['transparent', 'transparent'],
opacity: 0.2
},
column: {
colors: ['transparent', 'transparent'],
},
padding: {
left: 0,
right: 0
}
}
},
chartSeries: [
{
name: '',
data: []
}
],
messageTimeSeries: []
};
},
mounted() {
// 初始化时间范围选项
this.timeRanges = [
{ label: this.t('charts.messageTrend.timeRanges.1day'), value: 86400 },
{ label: this.t('charts.messageTrend.timeRanges.3days'), value: 259200 },
{ label: this.t('charts.messageTrend.timeRanges.1week'), value: 604800 },
{ label: this.t('charts.messageTrend.timeRanges.1month'), value: 2592000 },
];
this.selectedTimeRange = this.timeRanges[0];
// 设置图表翻译文本
this.chartOptions.tooltip.y.title.formatter = () => this.t('charts.messageTrend.messageCount') + ' ';
this.chartOptions.xaxis.title.text = this.t('charts.messageTrend.timeLabel');
this.chartOptions.yaxis.title.text = this.t('charts.messageTrend.messageCount');
this.chartSeries[0].name = this.t('charts.messageTrend.messageCount');
// 初始加载
this.fetchMessageSeries();
},
methods: {
formatNumber(num) {
return new Intl.NumberFormat('zh-CN').format(num);
},
async fetchMessageSeries() {
this.loading = true;
try {
const response = await axios.get(`/api/stat/get?offset_sec=${this.selectedTimeRange.value}`);
const data = response.data.data;
if (data && data.message_time_series) {
this.messageTimeSeries = data.message_time_series;
this.processTimeSeriesData();
}
} catch (error) {
console.error(this.t('status.dataError'), error);
} finally {
this.loading = false;
}
},
processTimeSeriesData() {
// 转换数据为图表格式
this.chartSeries[0].data = this.messageTimeSeries.map((item) => {
return [new Date(item[0]*1000).getTime(), item[1]];
});
// 计算总消息数
let total = 0;
this.messageTimeSeries.forEach(item => {
total += item[1];
});
this.totalMessages = this.formatNumber(total);
// 计算日平均
if (this.messageTimeSeries.length > 0) {
const daysSpan = this.selectedTimeRange.value / 86400; // 将秒转换为天数
this.dailyAverage = this.formatNumber(Math.round(total / daysSpan));
}
// 计算增长率
this.calculateGrowthRate();
},
calculateGrowthRate() {
if (this.messageTimeSeries.length < 4) {
this.growthRate = 0;
return;
}
// 计算前半部分和后半部分的消息总数
const halfIndex = Math.floor(this.messageTimeSeries.length / 2);
const firstHalf = this.messageTimeSeries
.slice(0, halfIndex)
.reduce((sum, item) => sum + item[1], 0);
const secondHalf = this.messageTimeSeries
.slice(halfIndex)
.reduce((sum, item) => sum + item[1], 0);
// 计算增长率
if (firstHalf > 0) {
this.growthRate = Math.round(((secondHalf - firstHalf) / firstHalf) * 100);
} else {
this.growthRate = secondHalf > 0 ? 100 : 0;
}
}
}
};
</script>
<style scoped>
.chart-card {
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
transition: transform 0.2s;
}
.chart-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.chart-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 20px;
}
.chart-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.chart-subtitle {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.time-select {
max-width: fit-content;
font-size: 14px;
}
.chart-stats {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-box {
padding: 12px 16px;
background: var(--v-theme-surface);
border-radius: 8px;
flex: 1;
}
.stat-label {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-bottom: 4px;
}
.stat-number {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
display: flex;
flex-wrap: wrap;
align-items: center;
}
.trend-up .stat-number {
color: var(--v-theme-success);
}
.trend-down .stat-number {
color: var(--v-theme-error);
}
.chart-container {
border-top: 1px solid var(--v-theme-border);
padding-top: 20px;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--v-theme-overlay);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: var(--v-theme-secondaryText);
}
</style>
@@ -1,90 +0,0 @@
<template>
<v-card elevation="1" class="stat-card platform-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-server-network" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ t('stats.onlinePlatform.title') }}</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ stat.platform_count || 0 }}</h2>
</div>
<div class="stat-subtitle">{{ t('stats.onlinePlatform.subtitle') }}</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'OnlinePlatform',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.platform-card {
background-color: #2196f3;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
margin-right: 8px;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
@@ -1,196 +0,0 @@
<template>
<div class="stats-container">
<v-card elevation="1" class="stat-card uptime-card mb-4">
<v-card-text>
<div class="d-flex align-center">
<div class="icon-wrapper">
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ tm('features.dashboard.status.uptime') }}</div>
<h3 class="uptime-value">{{ stat.running || tm('features.dashboard.status.loading') }}</h3>
</div>
<v-spacer></v-spacer>
<div class="uptime-status">
<v-icon icon="mdi-circle" size="10" color="success" class="blink-animation"></v-icon>
<span class="status-text">{{ tm('features.dashboard.status.online') }}</span>
</div>
</div>
</v-card-text>
</v-card>
<v-card elevation="1" class="stat-card memory-card">
<v-card-text>
<div class="d-flex align-center">
<div class="icon-wrapper">
<v-icon icon="mdi-memory" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ tm('features.dashboard.status.memoryUsage') }}</div>
<div class="memory-values">
<h3 class="memory-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB</span></h3>
<span class="memory-separator">/</span>
<h4 class="memory-total">{{ stat.memory?.system || 0 }} <span class="memory-unit">MiB</span></h4>
</div>
<v-progress-linear
:model-value="memoryPercentage"
color="warning"
height="4"
class="mt-2"
></v-progress-linear>
<div class="memory-percentage">{{ memoryPercentage }}%</div>
</div>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'OnlineTime',
setup() {
const { tm } = useModuleI18n('features/dashboard');
return { tm };
},
props: ['stat'],
data: () => ({
stat: {
memory: { process: 0, system: 0 },
running: "",
},
}),
computed: {
memoryPercentage() {
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
}
}
};
</script>
<style scoped>
.stats-container {
height: 100%;
display: flex;
flex-direction: column;
}
.stat-card {
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.uptime-card {
background-color: #4caf50;
color: white;
flex: 1;
}
.memory-card {
background-color: #ff9800;
color: white;
flex: 1;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.uptime-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.uptime-status {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.2);
padding: 4px 10px;
border-radius: 20px;
}
.status-text {
margin-left: 6px;
font-size: 12px;
font-weight: 500;
}
.memory-values {
display: flex;
align-items: baseline;
}
.memory-value {
font-size: 22px;
font-weight: 600;
}
.memory-separator {
margin: 0 6px;
font-weight: 300;
opacity: 0.7;
}
.memory-total {
font-size: 18px;
font-weight: 400;
opacity: 0.8;
}
.memory-unit {
font-size: 14px;
font-weight: 400;
opacity: 0.8;
}
.memory-percentage {
font-size: 12px;
margin-top: 4px;
text-align: right;
opacity: 0.9;
}
@keyframes blink {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.blink-animation {
animation: blink 1.5s infinite;
}
</style>
@@ -1,261 +0,0 @@
<template>
<v-card elevation="1" class="platform-stat-card">
<v-card-text>
<div class="platform-header">
<div>
<div class="platform-title">{{ t('charts.platformStat.title') }}</div>
<div class="platform-subtitle">{{ t('charts.platformStat.subtitle') }}</div>
</div>
</div>
<v-divider class="my-3"></v-divider>
<div v-if="platforms.length > 0" class="platform-list-container">
<v-list class="platform-list" density="compact">
<v-list-item
v-for="(platform, i) in sortedPlatforms"
:key="i"
:value="platform"
class="platform-item"
>
<template v-slot:prepend>
<div class="platform-rank" :class="{'top-rank': i < 3}">{{ i + 1 }}</div>
</template>
<v-list-item-title class="platform-name">{{ platform.name }}</v-list-item-title>
<template v-slot:append>
<div class="platform-count">
<span class="count-value">{{ platform.count }}</span>
<span class="count-label">{{ t('charts.platformStat.messageUnit') }}</span>
</div>
</template>
</v-list-item>
</v-list>
<div class="platform-stats-summary">
<div class="platform-stat-item">
<div class="stat-label">{{ t('charts.platformStat.platformCount') }}</div>
<div class="stat-value">{{ platforms.length }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">{{ t('charts.platformStat.mostActive') }}</div>
<div class="stat-value">{{ mostActivePlatform }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">{{ t('charts.platformStat.totalPercentage') }}</div>
<div class="stat-value">{{ topPlatformPercentage }}%</div>
</div>
</div>
<div class="platform-chart">
<v-progress-linear
v-for="(platform, i) in sortedPlatforms.slice(0, 5)"
:key="i"
:model-value="getPercentage(platform.count)"
height="8"
rounded
class="platform-progress"
:color="i === 0 ? 'primary' : i === 1 ? 'info' : i === 2 ? 'success' : 'grey-lighten-1'"
></v-progress-linear>
</div>
</div>
<div v-else class="no-data">
<v-icon icon="mdi-information-outline" size="40" color="grey-lighten-1"></v-icon>
<div class="no-data-text">{{ t('charts.platformStat.noData') }}</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'PlatformStat',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
data() {
return {
platforms: []
};
},
computed: {
sortedPlatforms() {
return [...this.platforms].sort((a, b) => b.count - a.count);
},
totalCount() {
return this.platforms.reduce((sum, platform) => sum + platform.count, 0);
},
mostActivePlatform() {
return this.sortedPlatforms.length > 0 ? this.sortedPlatforms[0].name : '-';
},
topPlatformPercentage() {
if (this.totalCount === 0 || this.sortedPlatforms.length === 0) return 0;
return Math.round((this.sortedPlatforms[0].count / this.totalCount) * 100);
}
},
watch: {
stat: {
handler: function (val) {
if (val && val.platform) {
this.platforms = val.platform;
}
},
deep: true,
}
},
methods: {
getPercentage(count) {
return this.totalCount ? (count / this.totalCount) * 100 : 0;
}
}
};
</script>
<style scoped>
.platform-stat-card {
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
transition: transform 0.2s;
}
.platform-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.platform-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.platform-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.platform-subtitle {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.platform-list-container {
display: flex;
flex-direction: column;
}
.platform-list {
max-height: 180px;
overflow-y: auto;
padding: 0;
margin-bottom: 16px;
}
.platform-item {
padding: 8px 16px;
margin-bottom: 4px;
border-radius: 8px;
transition: background-color 0.2s;
}
.platform-item:hover {
background-color: #f5f5f5;
}
.platform-rank {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--v-theme-surface);
color: var(--v-theme-primaryText);
font-weight: 600;
font-size: 14px;
margin-right: 12px;
}
.top-rank {
background-color: var(--v-theme-secondary);
color: var(--v-theme-surface);
}
.platform-name {
font-weight: 500;
}
.platform-count {
display: flex;
align-items: center;
}
.count-value {
font-weight: 600;
font-size: 14px;
color: var(--v-theme-secondary);
margin-right: 4px;
}
.count-label {
font-size: 12px;
color: var(--v-theme-secondaryText);
}
.platform-stats-summary {
display: flex;
justify-content: space-between;
background-color: var(--v-theme-containerBg);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.platform-stat-item {
flex: 1;
text-align: center;
}
.stat-label {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-bottom: 4px;
}
.stat-value {
font-weight: 600;
color: var(--v-theme-primaryText);
}
.platform-chart {
margin-top: 8px;
}
.platform-progress {
margin-bottom: 12px;
}
.no-data {
height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.no-data-text {
color: var(--v-theme-secondaryText);
margin-top: 16px;
font-size: 14px;
}
</style>
@@ -1,104 +0,0 @@
<template>
<v-card elevation="1" class="stat-card uptime-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ t('stats.runningTime.title') }}</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ formattedTime }}</h2>
</div>
<div class="stat-subtitle">{{ t('stats.runningTime.subtitle') }}</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'RunningTime',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
computed: {
formattedTime() {
if (!this.stat?.running) {
return this.t('status.loading');
}
const { hours, minutes, seconds } = this.stat.running;
return this.t('stats.runningTime.format', {
hours,
minutes,
seconds
});
}
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.uptime-card {
background-color: #4caf50;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
@@ -1,103 +0,0 @@
<template>
<v-card elevation="1" class="stat-card message-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-message-text-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">{{ t('stats.totalMessage.title') }}</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ formattedCount }}</h2>
<v-chip v-if="stat.daily_increase" class="trend-chip" size="x-small" color="success">
+{{ stat.daily_increase }}
</v-chip>
</div>
<div class="stat-subtitle">{{ t('stats.totalMessage.subtitle') }}</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'TotalMessage',
props: ['stat'],
setup() {
const { tm: t } = useModuleI18n('features/dashboard');
return { t };
},
computed: {
formattedCount() {
const count = this.stat?.message_count;
return count ? count.toLocaleString() : '0';
}
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.message-card {
background-color: #5e35b1;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
margin-right: 8px;
}
.trend-chip {
font-weight: 600;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
from types import SimpleNamespace
import pytest
from sqlmodel import select
from astrbot.core.agent.response import AgentStats
from astrbot.core.db.po import ProviderStat
from astrbot.core.pipeline.process_stage.method.agent_sub_stages import internal
from astrbot.core.provider.entities import ProviderRequest, TokenUsage
@pytest.mark.asyncio
async def test_record_internal_agent_stats_persists_provider_stat(
temp_db,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(internal, "db_helper", temp_db)
event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42")
req = ProviderRequest(
conversation=SimpleNamespace(cid="conv-123"),
)
stats = AgentStats(
token_usage=TokenUsage(input_other=11, input_cached=3, output=7),
start_time=100.0,
end_time=108.5,
time_to_first_token=0.6,
)
provider = SimpleNamespace(
provider_config={"id": "provider-1"},
meta=lambda: SimpleNamespace(id="provider-1", type="openai"),
get_model=lambda: "gpt-4.1",
)
agent_runner = SimpleNamespace(
provider=provider,
stats=stats,
was_aborted=lambda: False,
)
final_resp = SimpleNamespace(role="assistant")
await internal._record_internal_agent_stats(
event,
req,
agent_runner,
final_resp,
)
async with temp_db.get_db() as session:
result = await session.execute(select(ProviderStat))
records = result.scalars().all()
assert len(records) == 1
record = records[0]
assert record.agent_type == "internal"
assert record.status == "completed"
assert record.umo == "webchat:FriendMessage:session-42"
assert record.conversation_id == "conv-123"
assert record.provider_id == "provider-1"
assert record.provider_model == "gpt-4.1"
assert record.token_input_other == 11
assert record.token_input_cached == 3
assert record.token_output == 7
assert record.start_time == 100.0
assert record.end_time == 108.5
assert record.time_to_first_token == 0.6