feat: enhance platform management with status tracking and error handling

- Introduced PlatformStatus enum to manage platform states (pending, running, error, stopped).
- Added error recording and retrieval functionality in the Platform class.
- Implemented a new method in PlatformManager to gather statistics for all platforms.
- Updated the dashboard to display platform statuses and error details, including a dialog for error insights.
- Enhanced localization for runtime statuses and error dialogs in both English and Chinese.
This commit is contained in:
Soulter
2025-12-03 16:48:57 +08:00
parent 5714944eef
commit 54e49b997b
6 changed files with 370 additions and 10 deletions

View File

@@ -6,7 +6,7 @@ from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from .platform import Platform
from .platform import Platform, PlatformStatus
from .register import platform_cls_map
from .sources.webchat.webchat_adapter import WebChatAdapter
@@ -16,7 +16,7 @@ class PlatformManager:
self.platform_insts: list[Platform] = []
"""加载的 Platform 的实例"""
self._inst_map = {}
self._inst_map: dict[str, dict] = {}
self.platforms_config = config["platform"]
self.settings = config["platform_settings"]
@@ -37,7 +37,10 @@ class PlatformManager:
webchat_inst = WebChatAdapter({}, self.settings, self.event_queue)
self.platform_insts.append(webchat_inst)
asyncio.create_task(
self._task_wrapper(asyncio.create_task(webchat_inst.run(), name="webchat")),
self._task_wrapper(
asyncio.create_task(webchat_inst.run(), name="webchat"),
platform=webchat_inst,
),
)
async def load_platform(self, platform_config: dict):
@@ -131,6 +134,7 @@ class PlatformManager:
inst.run(),
name=f"platform_{platform_config['type']}_{platform_config['id']}",
),
platform=inst,
),
)
handlers = star_handlers_registry.get_handlers_by_event_type(
@@ -145,17 +149,28 @@ class PlatformManager:
except Exception:
logger.error(traceback.format_exc())
async def _task_wrapper(self, task: asyncio.Task):
async def _task_wrapper(self, task: asyncio.Task, platform: Platform | None = None):
# 设置平台状态为运行中
if platform:
platform.status = PlatformStatus.RUNNING
try:
await task
except asyncio.CancelledError:
pass
if platform:
platform.status = PlatformStatus.STOPPED
except Exception as e:
error_msg = str(e)
tb_str = traceback.format_exc()
logger.error(f"------- 任务 {task.get_name()} 发生错误: {e}")
for line in traceback.format_exc().split("\n"):
for line in tb_str.split("\n"):
logger.error(f"| {line}")
logger.error("-------")
# 记录错误到平台实例
if platform:
platform.record_error(error_msg, tb_str)
async def reload(self, platform_config: dict):
await self.terminate_platform(platform_config["id"])
if platform_config["enable"]:
@@ -172,9 +187,9 @@ class PlatformManager:
logger.info(f"正在尝试终止 {platform_id} 平台适配器 ...")
# client_id = self._inst_map.pop(platform_id, None)
info = self._inst_map.pop(platform_id, None)
info = self._inst_map.pop(platform_id)
client_id = info["client_id"]
inst = info["inst"]
inst: Platform = info["inst"]
try:
self.platform_insts.remove(
next(
@@ -196,3 +211,46 @@ class PlatformManager:
def get_insts(self):
return self.platform_insts
def get_all_stats(self) -> dict:
"""获取所有平台的统计信息
Returns:
包含所有平台统计信息的字典
"""
stats_list = []
total_errors = 0
running_count = 0
error_count = 0
for inst in self.platform_insts:
try:
stat = inst.get_stats()
stats_list.append(stat)
total_errors += stat.get("error_count", 0)
if stat.get("status") == PlatformStatus.RUNNING.value:
running_count += 1
elif stat.get("status") == PlatformStatus.ERROR.value:
error_count += 1
except Exception as e:
# 如果获取统计信息失败,记录基本信息
logger.warning(f"获取平台统计信息失败: {e}")
stats_list.append(
{
"id": getattr(inst, "config", {}).get("id", "unknown"),
"type": "unknown",
"status": "unknown",
"error_count": 0,
"last_error": None,
}
)
return {
"platforms": stats_list,
"summary": {
"total": len(stats_list),
"running": running_count,
"error": error_count,
"total_errors": total_errors,
},
}

View File

@@ -2,6 +2,9 @@ import abc
import uuid
from asyncio import Queue
from collections.abc import Awaitable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
from astrbot.core.message.message_event_result import MessageChain
@@ -12,6 +15,24 @@ from .message_session import MessageSesion
from .platform_metadata import PlatformMetadata
class PlatformStatus(Enum):
"""平台运行状态"""
PENDING = "pending" # 待启动
RUNNING = "running" # 运行中
ERROR = "error" # 发生错误
STOPPED = "stopped" # 已停止
@dataclass
class PlatformError:
"""平台错误信息"""
message: str
timestamp: datetime = field(default_factory=datetime.now)
traceback: str | None = None
class Platform(abc.ABC):
def __init__(self, config: dict, event_queue: Queue):
super().__init__()
@@ -21,6 +42,63 @@ class Platform(abc.ABC):
self._event_queue = event_queue
self.client_self_id = uuid.uuid4().hex
# 平台运行状态
self._status: PlatformStatus = PlatformStatus.PENDING
self._errors: list[PlatformError] = []
self._started_at: datetime | None = None
@property
def status(self) -> PlatformStatus:
"""获取平台运行状态"""
return self._status
@status.setter
def status(self, value: PlatformStatus):
"""设置平台运行状态"""
self._status = value
if value == PlatformStatus.RUNNING and self._started_at is None:
self._started_at = datetime.now()
@property
def errors(self) -> list[PlatformError]:
"""获取错误列表"""
return self._errors
@property
def last_error(self) -> PlatformError | None:
"""获取最近的错误"""
return self._errors[-1] if self._errors else None
def record_error(self, message: str, traceback_str: str | None = None):
"""记录一个错误"""
self._errors.append(PlatformError(message=message, traceback=traceback_str))
self._status = PlatformStatus.ERROR
def clear_errors(self):
"""清除错误记录"""
self._errors.clear()
if self._status == PlatformStatus.ERROR:
self._status = PlatformStatus.RUNNING
def get_stats(self) -> dict:
"""获取平台统计信息"""
meta = self.meta()
return {
"id": meta.id or self.config.get("id"),
"type": meta.name,
"display_name": meta.adapter_display_name or meta.name,
"status": self._status.value,
"started_at": self._started_at.isoformat() if self._started_at else None,
"error_count": len(self._errors),
"last_error": {
"message": self.last_error.message,
"timestamp": self.last_error.timestamp.isoformat(),
"traceback": self.last_error.traceback,
}
if self.last_error
else None,
}
@abc.abstractmethod
def run(self) -> Awaitable[Any]:
"""得到一个平台的运行实例,需要返回一个协程对象。"""

View File

@@ -24,8 +24,6 @@ class PlatformRoute(Route):
self.core_lifecycle = core_lifecycle
self.platform_manager = core_lifecycle.platform_manager
# 路由不使用标准的 /api 前缀,因为 webhook 回调需要直接访问
# 所以我们手动注册路由
self._register_webhook_routes()
def _register_webhook_routes(self):
@@ -37,6 +35,13 @@ class PlatformRoute(Route):
methods=["GET", "POST"],
)
# 平台统计信息接口
self.app.add_url_rule(
"/api/platform/stats",
view_func=self.get_platform_stats,
methods=["GET"],
)
async def unified_webhook_callback(self, webhook_uuid: str):
"""统一 webhook 回调入口
@@ -80,3 +85,16 @@ class PlatformRoute(Route):
if platform.config.get("unified_webhook_mode", False):
return platform
return None
async def get_platform_stats(self):
"""获取所有平台的统计信息
Returns:
包含平台统计信息的响应
"""
try:
stats = self.platform_manager.get_all_stats()
return Response().ok(stats).__dict__
except Exception as e:
logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
return Response().error(f"获取统计信息失败: {e}").__dict__, 500

View File

@@ -58,5 +58,22 @@
"connected": "Connected",
"disconnected": "Disconnected",
"error": "Error"
},
"runtimeStatus": {
"running": "Running",
"error": "Error",
"pending": "Pending",
"stopped": "Stopped",
"unknown": "Unknown",
"errors": "error(s)"
},
"errorDialog": {
"title": "Error Details",
"platformId": "Platform ID",
"errorCount": "Error Count",
"lastError": "Last Error",
"occurredAt": "Occurred At",
"traceback": "Traceback",
"close": "Close"
}
}

View File

@@ -58,5 +58,22 @@
"connected": "已连接",
"disconnected": "已断开",
"error": "错误"
},
"runtimeStatus": {
"running": "运行中",
"error": "发生错误",
"pending": "等待启动",
"stopped": "已停止",
"unknown": "未知",
"errors": "个错误"
},
"errorDialog": {
"title": "错误详情",
"platformId": "平台 ID",
"errorCount": "错误数量",
"lastError": "最近错误",
"occurredAt": "发生时间",
"traceback": "错误堆栈",
"close": "关闭"
}
}

View File

@@ -30,6 +30,33 @@
:bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template #item-details="{ item }">
<!-- 平台运行状态 - 只在非运行状态或有错误时显示 -->
<div class="platform-status-row mb-2" v-if="getPlatformStat(item.id) && (getPlatformStat(item.id)?.status !== 'running' || getPlatformStat(item.id)?.error_count > 0)">
<!-- 状态 chip - 只在非 running 状态时显示 -->
<v-chip
v-if="getPlatformStat(item.id)?.status !== 'running'"
size="small"
:color="getStatusColor(getPlatformStat(item.id)?.status)"
variant="tonal"
class="status-chip"
>
<v-icon size="small" start>{{ getStatusIcon(getPlatformStat(item.id)?.status) }}</v-icon>
{{ tm('runtimeStatus.' + (getPlatformStat(item.id)?.status || 'unknown')) }}
</v-chip>
<!-- 错误数量提示 -->
<v-chip
v-if="getPlatformStat(item.id)?.error_count > 0"
size="small"
color="error"
variant="tonal"
class="error-chip"
:class="{ 'ms-2': getPlatformStat(item.id)?.status !== 'running' }"
@click.stop="showErrorDetails(item)"
>
<v-icon size="small" start>mdi-bug</v-icon>
{{ getPlatformStat(item.id)?.error_count }} {{ tm('runtimeStatus.errors') }}
</v-chip>
</div>
<div v-if="item.unified_webhook_mode && item.webhook_uuid" class="webhook-info">
<v-chip
size="small"
@@ -111,6 +138,47 @@
</v-card>
</v-dialog>
<!-- 错误详情对话框 -->
<v-dialog v-model="showErrorDialog" max-width="700">
<v-card>
<v-card-title class="d-flex align-center pa-4">
<v-icon class="me-2" color="error">mdi-alert-circle</v-icon>
{{ tm('errorDialog.title') }}
</v-card-title>
<v-card-text class="px-4 pb-4" v-if="currentErrorPlatform">
<div class="mb-3">
<strong>{{ tm('errorDialog.platformId') }}:</strong> {{ currentErrorPlatform.id }}
</div>
<div class="mb-3">
<strong>{{ tm('errorDialog.errorCount') }}:</strong> {{ currentErrorPlatform.error_count }}
</div>
<div v-if="currentErrorPlatform.last_error" class="error-details">
<div class="mb-2">
<strong>{{ tm('errorDialog.lastError') }}:</strong>
</div>
<v-alert type="error" variant="tonal" class="mb-3">
<div class="error-message">{{ currentErrorPlatform.last_error.message }}</div>
<div class="error-time text-caption text-medium-emphasis mt-1">
{{ tm('errorDialog.occurredAt') }}: {{ new Date(currentErrorPlatform.last_error.timestamp).toLocaleString() }}
</div>
</v-alert>
<div v-if="currentErrorPlatform.last_error.traceback">
<div class="mb-2">
<strong>{{ tm('errorDialog.traceback') }}:</strong>
</div>
<pre class="traceback-box">{{ currentErrorPlatform.last_error.traceback }}</pre>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer></v-spacer>
<v-btn variant="tonal" color="primary" @click="showErrorDialog = false">
{{ tm('errorDialog.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
@@ -167,6 +235,14 @@ export default {
showWebhookDialog: false,
currentWebhookUuid: '',
// 平台统计信息
platformStats: {},
statsRefreshInterval: null,
// 错误详情对话框
showErrorDialog: false,
currentErrorPlatform: null,
store: useCommonStore()
}
},
@@ -189,6 +265,17 @@ export default {
mounted() {
this.getConfig();
this.getPlatformStats();
// 每 10 秒刷新一次平台状态
this.statsRefreshInterval = setInterval(() => {
this.getPlatformStats();
}, 10000);
},
beforeUnmount() {
if (this.statsRefreshInterval) {
clearInterval(this.statsRefreshInterval);
}
},
methods: {
@@ -213,6 +300,53 @@ export default {
});
},
getPlatformStats() {
axios.get('/api/platform/stats').then((res) => {
if (res.data.status === 'ok') {
// 将数组转换为以 id 为 key 的对象,方便查找
const stats = {};
for (const platform of res.data.data.platforms || []) {
stats[platform.id] = platform;
}
this.platformStats = stats;
}
}).catch((err) => {
console.warn('获取平台统计信息失败:', err);
});
},
getPlatformStat(platformId) {
return this.platformStats[platformId] || null;
},
getStatusColor(status) {
switch (status) {
case 'running': return 'success';
case 'error': return 'error';
case 'pending': return 'warning';
case 'stopped': return 'grey';
default: return 'grey';
}
},
getStatusIcon(status) {
switch (status) {
case 'running': return 'mdi-check-circle';
case 'error': return 'mdi-alert-circle';
case 'pending': return 'mdi-clock-outline';
case 'stopped': return 'mdi-stop-circle';
default: return 'mdi-help-circle';
}
},
showErrorDetails(platform) {
const stat = this.getPlatformStat(platform.id);
if (stat && stat.error_count > 0) {
this.currentErrorPlatform = stat;
this.showErrorDialog = true;
}
},
editPlatform(platform) {
this.updatingPlatformConfig = JSON.parse(JSON.stringify(platform));
this.updatingMode = true;
@@ -326,4 +460,42 @@ export default {
.webhook-chip {
cursor: pointer;
}
.platform-status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.status-chip {
font-size: 12px;
}
.error-chip {
cursor: pointer;
font-size: 12px;
}
.error-details {
margin-top: 8px;
}
.error-message {
word-break: break-word;
}
.traceback-box {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
</style>