Commit 839ab37d authored by yangjianbo's avatar yangjianbo
Browse files
parents 9dd0ef18 fd8473f2
......@@ -128,6 +128,7 @@ export default {
noData: '暂无数据',
success: '成功',
error: '错误',
critical: '严重',
warning: '警告',
info: '提示',
active: '启用',
......@@ -142,6 +143,8 @@ export default {
copiedToClipboard: '已复制到剪贴板',
copyFailed: '复制失败',
contactSupport: '联系客服',
add: '添加',
invalidEmail: '请输入有效的邮箱地址',
optional: '可选',
selectOption: '请选择',
searchPlaceholder: '搜索...',
......@@ -151,6 +154,7 @@ export default {
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
settings: '设置',
notAvailable: '不可用',
now: '现在',
unknown: '未知',
......@@ -176,6 +180,7 @@ export default {
accounts: '账号管理',
proxies: 'IP管理',
redeemCodes: '兑换码',
ops: '运维监控',
promoCodes: '优惠码',
settings: '系统设置',
myAccount: '我的账户',
......@@ -361,6 +366,12 @@ export default {
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
noteWindows: '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
},
cliTabs: {
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
opencode: 'OpenCode',
},
antigravity: {
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
claudeCode: 'Claude Code',
......@@ -373,6 +384,11 @@ export default {
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
},
opencode: {
title: 'OpenCode 配置示例',
subtitle: 'opencode.json',
hint: '示例仅用于演示分组配置,模型与选项可按需调整。',
},
},
customKeyLabel: '自定义密钥',
customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
......@@ -1971,6 +1987,565 @@ export default {
ipAddress: 'IP'
},
// Ops Monitoring
ops: {
title: '运维监控',
description: '运维监控与排障',
// Dashboard
systemHealth: '系统健康',
overview: '概览',
noSystemMetrics: '尚未收集系统指标。',
collectedAt: '采集时间:',
window: '窗口',
cpu: 'CPU',
memory: '内存',
db: '数据库',
redis: 'Redis',
goroutines: '协程',
jobs: '后台任务',
jobsHelp: '点击“明细”查看任务心跳与报错信息',
active: '活跃',
idle: '空闲',
waiting: '等待',
conns: '连接',
queue: '队列',
ok: '正常',
lastRun: '最近运行',
lastSuccess: '最近成功',
lastError: '最近错误',
noData: '暂无数据',
loadingText: '加载中...',
ready: '就绪',
requestsTotal: '请求(总计)',
slaScope: 'SLA 范围:',
tokens: 'Token',
tps: 'TPS',
current: '当前',
peak: '峰值',
average: '平均',
totalRequests: '总请求',
avgQps: '平均 QPS',
avgTps: '平均 TPS',
avgLatency: '平均延迟',
avgTtft: '平均首字延迟',
exceptions: '异常数',
requestErrors: '请求错误',
errorCount: '错误数',
upstreamErrors: '上游错误',
errorCountExcl429529: '错误数(排除429/529)',
sla: 'SLA(排除业务限制)',
businessLimited: '业务限制:',
errors: '错误',
errorRate: '错误率:',
upstreamRate: '上游错误率:',
latencyDuration: '延迟(毫秒)',
ttftLabel: '首字延迟(毫秒)',
p50: 'p50',
p90: 'p90',
p95: 'p95',
p99: 'p99',
avg: 'avg',
max: 'max',
qps: 'QPS',
requests: '请求',
upstream: '上游',
client: '客户端',
system: '系统',
other: '其他',
errorsSla: '错误(SLA范围)',
upstreamExcl429529: '上游(排除429/529)',
failedToLoadData: '加载运维数据失败',
failedToLoadOverview: '加载概览数据失败',
failedToLoadThroughputTrend: '加载吞吐趋势失败',
failedToLoadLatencyHistogram: '加载延迟分布失败',
failedToLoadErrorTrend: '加载错误趋势失败',
failedToLoadErrorDistribution: '加载错误分布失败',
failedToLoadErrorDetail: '加载错误详情失败',
retryFailed: '重试失败',
tpsK: 'TPS(千)',
top: '最高:',
throughputTrend: '吞吐趋势',
latencyHistogram: '延迟分布',
errorTrend: '错误趋势',
errorDistribution: '错误分布',
// Health Score & Diagnosis
health: '健康',
healthCondition: '健康状况',
healthHelp: '基于 SLA、错误率和资源使用情况的系统整体健康评分',
healthyStatus: '健康',
riskyStatus: '风险',
idleStatus: '待机',
timeRange: {
'5m': '近5分钟',
'30m': '近30分钟',
'1h': '近1小时',
'6h': '近6小时',
'24h': '近24小时'
},
diagnosis: {
title: '智能诊断',
footer: '基于当前指标的自动诊断建议',
idle: '系统当前处于待机状态',
idleImpact: '无活跃流量',
// Resource diagnostics
dbDown: '数据库连接失败',
dbDownImpact: '所有数据库操作将失败',
dbDownAction: '检查数据库服务状态、网络连接和连接配置',
redisDown: 'Redis连接失败',
redisDownImpact: '缓存功能降级,性能可能下降',
redisDownAction: '检查Redis服务状态和网络连接',
cpuCritical: 'CPU使用率严重过高 ({usage}%)',
cpuCriticalImpact: '系统响应变慢,可能影响所有请求',
cpuCriticalAction: '检查CPU密集型任务,考虑扩容或优化代码',
cpuHigh: 'CPU使用率偏高 ({usage}%)',
cpuHighImpact: '系统负载较高,需要关注',
cpuHighAction: '监控CPU趋势,准备扩容方案',
memoryCritical: '内存使用率严重过高 ({usage}%)',
memoryCriticalImpact: '可能触发OOM,系统稳定性受威胁',
memoryCriticalAction: '检查内存泄漏,考虑增加内存或优化内存使用',
memoryHigh: '内存使用率偏高 ({usage}%)',
memoryHighImpact: '内存压力较大,需要关注',
memoryHighAction: '监控内存趋势,检查是否有内存泄漏',
// Latency diagnostics
latencyCritical: '响应延迟严重过高 ({latency}ms)',
latencyCriticalImpact: '用户体验极差,大量请求超时',
latencyCriticalAction: '检查慢查询、数据库索引、网络延迟和上游服务',
latencyHigh: '响应延迟偏高 ({latency}ms)',
latencyHighImpact: '用户体验下降,需要优化',
latencyHighAction: '分析慢请求日志,优化数据库查询和业务逻辑',
ttftHigh: '首字节时间偏高 ({ttft}ms)',
ttftHighImpact: '用户感知延迟增加',
ttftHighAction: '优化请求处理流程,减少前置逻辑耗时',
// Error rate diagnostics
upstreamCritical: '上游错误率严重偏高 ({rate}%)',
upstreamCriticalImpact: '可能影响大量用户请求',
upstreamCriticalAction: '检查上游服务健康状态,启用降级策略',
upstreamHigh: '上游错误率偏高 ({rate}%)',
upstreamHighImpact: '建议检查上游服务状态',
upstreamHighAction: '联系上游服务团队,准备降级方案',
errorHigh: '错误率过高 ({rate}%)',
errorHighImpact: '大量请求失败',
errorHighAction: '查看错误日志,定位错误根因,紧急修复',
errorElevated: '错误率偏高 ({rate}%)',
errorElevatedImpact: '建议检查错误日志',
errorElevatedAction: '分析错误类型和分布,制定修复计划',
// SLA diagnostics
slaCritical: 'SLA 严重低于目标 ({sla}%)',
slaCriticalImpact: '用户体验严重受损',
slaCriticalAction: '紧急排查错误和延迟问题,考虑限流保护',
slaLow: 'SLA 低于目标 ({sla}%)',
slaLowImpact: '需要关注服务质量',
slaLowAction: '分析SLA下降原因,优化系统性能',
// Health score diagnostics
healthCritical: '综合健康评分过低 ({score})',
healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与延迟',
healthCriticalAction: '全面检查系统状态,优先处理critical级别问题',
healthLow: '综合健康评分偏低 ({score})',
healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率',
healthLowAction: '监控指标趋势,预防问题恶化',
healthy: '所有系统指标正常',
healthyImpact: '服务运行稳定'
},
// Error Log
errorLog: {
timeId: '时间 / ID',
context: '上下文',
status: '状态码',
message: '消息',
latency: '延迟',
action: '操作',
noErrors: '该窗口内暂无错误。',
grp: 'GRP:',
acc: 'ACC:',
details: '详情',
phase: '阶段'
},
// Error Details Modal
errorDetails: {
upstreamErrors: '上游错误',
requestErrors: '请求错误',
total: '总计:',
searchPlaceholder: '搜索 request_id / client_request_id / message',
accountIdPlaceholder: 'account_id'
},
// Error Detail Modal
errorDetail: {
loading: '加载中…',
requestId: '请求 ID',
time: '时间',
phase: '阶段',
status: '状态码',
message: '消息',
basicInfo: '基本信息',
platform: '平台',
model: '模型',
latency: '延迟',
ttft: 'TTFT',
businessLimited: '业务限制',
requestPath: '请求路径',
timings: '时序信息',
auth: '认证',
routing: '路由',
upstream: '上游',
response: '响应',
retry: '重试',
retryClient: '重试(客户端)',
retryUpstream: '重试(上游固定)',
pinnedAccountId: '固定 account_id',
retryNotes: '重试说明',
requestBody: '请求体',
errorBody: '错误体',
trimmed: '已截断',
confirmRetry: '确认重试',
retrySuccess: '重试成功',
retryFailed: '重试失败',
na: 'N/A',
retryHint: '重试将使用相同的请求参数重新发送请求',
retryClientHint: '使用客户端重试(不固定账号)',
retryUpstreamHint: '使用上游固定重试(固定到错误的账号)',
pinnedAccountIdHint: '(自动从错误日志获取)',
retryNote1: '重试会使用相同的请求体和参数',
retryNote2: '如果原请求失败是因为账号问题,固定重试可能仍会失败',
retryNote3: '客户端重试会重新选择账号',
confirmRetryMessage: '确认要重试该请求吗?',
confirmRetryHint: '将使用相同的请求参数重新发送'
},
requestDetails: {
title: '请求明细',
details: '明细',
rangeLabel: '窗口:{range}',
rangeMinutes: '{n} 分钟',
rangeHours: '{n} 小时',
empty: '该窗口内暂无请求。',
emptyHint: '可尝试调整时间范围或取消部分筛选。',
failedToLoad: '加载请求明细失败',
requestIdCopied: '请求ID已复制',
copyFailed: '复制失败',
copy: '复制',
viewError: '查看错误',
kind: {
success: '成功',
error: '失败'
},
table: {
time: '时间',
kind: '类型',
platform: '平台',
model: '模型',
duration: '耗时',
status: '状态码',
requestId: '请求ID',
actions: '操作'
}
},
alertEvents: {
title: '告警事件',
description: '最近的告警触发/恢复记录(仅邮件通知)',
loading: '加载中...',
empty: '暂无告警事件',
loadFailed: '加载告警事件失败',
table: {
time: '时间',
status: '状态',
severity: '级别',
title: '标题',
metric: '指标 / 阈值',
email: '邮件已发送'
}
},
alertRules: {
title: '告警规则',
description: '创建与管理系统阈值告警(仅邮件通知)',
loading: '加载中...',
empty: '暂无告警规则',
loadFailed: '加载告警规则失败',
saveSuccess: '警报规则保存成功',
saveFailed: '保存告警规则失败',
deleteSuccess: '警报规则删除成功',
deleteFailed: '删除告警规则失败',
create: '新建规则',
createTitle: '新建告警规则',
editTitle: '编辑告警规则',
deleteConfirmTitle: '确认删除该规则?',
deleteConfirmMessage: '将删除该规则及其关联的告警事件,是否继续?',
manage: '预警规则',
metricGroups: {
system: '系统指标',
group: '分组级别指标(需 group_id)',
account: '账号级别指标'
},
metrics: {
successRate: '成功率 (%)',
errorRate: '错误率 (%)',
upstreamErrorRate: '上游错误率 (%)',
p95: 'P95 延迟 (ms)',
p99: 'P99 延迟 (ms)',
cpu: 'CPU 使用率 (%)',
memory: '内存使用率 (%)',
queueDepth: '并发排队深度',
groupAvailableAccounts: '分组可用账号数',
groupAvailableRatio: '分组可用比例 (%)',
groupRateLimitRatio: '分组限流比例 (%)',
accountRateLimitedCount: '限流账号数',
accountErrorCount: '错误账号数(不含临时不可调度)',
accountErrorRatio: '错误账号比例 (%)',
overloadAccountCount: '过载账号数'
},
metricDescriptions: {
successRate: '统计窗口内成功请求占比(0~100)。',
errorRate: '统计窗口内失败请求占比(0~100)。',
upstreamErrorRate: '统计窗口内上游错误占比(0~100)。',
p95: '统计窗口内 P95 请求耗时(毫秒)。',
p99: '统计窗口内 P99 请求耗时(毫秒)。',
cpu: '当前实例 CPU 使用率(0~100)。',
memory: '当前实例内存使用率(0~100)。',
queueDepth: '统计窗口内并发队列排队深度(等待中的请求数)。',
groupAvailableAccounts: '指定分组中当前可用账号数量(需要 group_id 过滤)。',
groupAvailableRatio: '指定分组中可用账号占比(0~100,需要 group_id 过滤)。',
groupRateLimitRatio: '指定分组中账号被限流的比例(0~100,需要 group_id 过滤)。',
accountRateLimitedCount: '统计窗口内被限流的账号数量。',
accountErrorCount: '统计窗口内产生错误的账号数量(不含临时不可调度)。',
accountErrorRatio: '统计窗口内错误账号占比(0~100)。',
overloadAccountCount: '统计窗口内过载账号数量。'
},
hints: {
recommended: '推荐:运算符 {operator},阈值 {threshold}{unit}',
groupRequired: '该指标为分组级别指标,必须选择分组(group_id)。',
groupOptional: '可选:通过 group_id 将规则限定到某个分组。'
},
table: {
name: '名称',
metric: '指标',
severity: '级别',
enabled: '启用',
actions: '操作'
},
form: {
name: '名称',
description: '描述',
metric: '指标',
operator: '运算符',
groupId: '分组(group_id)',
groupPlaceholder: '请选择分组',
allGroups: '全部分组',
threshold: '阈值',
severity: '级别',
window: '统计窗口(分钟)',
sustained: '连续样本数(每分钟)',
cooldown: '冷却期(分钟)',
enabled: '启用',
notifyEmail: '发送邮件通知'
},
validation: {
title: '请先修正以下问题',
invalid: '规则不合法',
nameRequired: '名称不能为空',
metricRequired: '指标不能为空',
groupIdRequired: '分组级别指标必须指定 group_id',
operatorRequired: '运算符不能为空',
thresholdRequired: '阈值必须为数字',
windowRange: '统计窗口必须为 1 / 5 / 60 分钟之一',
sustainedRange: '连续样本数必须在 1 到 1440 之间',
cooldownRange: '冷却期必须在 0 到 1440 分钟之间'
}
},
runtime: {
title: '运维监控运行设置',
description: '配置存储在数据库中,无需修改 config 文件即可生效。',
loading: '加载中...',
noData: '暂无运行设置',
loadFailed: '加载运行设置失败',
saveSuccess: '运行设置已保存',
saveFailed: '保存运行设置失败',
alertTitle: '告警评估器',
groupAvailabilityTitle: '分组可用性监控',
evalIntervalSeconds: '评估间隔(秒)',
silencing: {
title: '告警静默(维护模式)',
enabled: '启用静默',
globalUntil: '静默截止时间(RFC3339)',
untilPlaceholder: '2026-01-05T00:00:00Z',
untilHint: '建议填写截止时间,避免忘记关闭静默。',
reason: '原因',
reasonPlaceholder: '例如:计划维护',
entries: {
title: '高级:定向静默',
hint: '可选:仅静默特定规则或特定级别。字段留空表示匹配全部。',
add: '新增条目',
empty: '暂无定向静默条目',
entryTitle: '条目 #{n}',
ruleId: '规则ID(可选)',
ruleIdPlaceholder: '例如:1',
severities: '级别(可选)',
severitiesPlaceholder: '例如:P0,P1(留空=全部)',
until: '截止时间(RFC3339)',
reason: '原因',
validation: {
untilRequired: '条目截止时间不能为空',
untilFormat: '条目截止时间必须为合法的 RFC3339 时间戳',
ruleIdPositive: '条目 rule_id 必须为正整数',
severitiesFormat: '条目级别必须为 P0..P3 的逗号分隔列表'
}
},
validation: {
timeFormat: '静默时间必须为合法的 RFC3339 时间戳'
}
},
lockEnabled: '启用分布式锁',
lockKey: '分布式锁 Key',
lockTTLSeconds: '分布式锁 TTL(秒)',
showAdvancedDeveloperSettings: '显示高级开发者设置 (Distributed Lock)',
advancedSettingsSummary: '高级设置 (分布式锁)',
evalIntervalHint: '检测任务的执行频率,建议保持默认。',
validation: {
title: '请先修正以下问题',
invalid: '设置不合法',
evalIntervalRange: '评估间隔必须在 1 到 86400 秒之间',
lockKeyRequired: '启用分布式锁时必须填写 Lock Key',
lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头',
lockKeyHint: '建议以「{prefix}」开头以避免冲突',
lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间'
}
},
email: {
title: '邮件通知配置',
description: '配置告警/报告邮件通知(存储在数据库中)。',
loading: '加载中...',
noData: '暂无邮件通知配置',
loadFailed: '加载邮件通知配置失败',
saveSuccess: '邮件通知配置已保存',
saveFailed: '保存邮件通知配置失败',
alertTitle: '告警邮件',
reportTitle: '报告邮件',
recipients: '收件人',
recipientsHint: '若为空,系统可能会回退使用第一个管理员邮箱。',
minSeverity: '最低级别',
minSeverityAll: '全部级别',
rateLimitPerHour: '每小时限额',
batchWindowSeconds: '合并窗口(秒)',
includeResolved: '包含恢复通知',
dailySummary: '每日摘要',
weeklySummary: '每周摘要',
errorDigest: '错误摘要',
errorDigestMinCount: '错误摘要最小数量',
accountHealth: '账号健康报告',
accountHealthThreshold: '错误率阈值(%)',
cronPlaceholder: 'Cron 表达式',
reportHint: '发送时间使用 Cron 语法;留空将使用默认值。',
validation: {
title: '请先修正以下问题',
invalid: '邮件通知配置不合法',
alertRecipientsRequired: '已启用告警邮件,但未配置任何收件人',
reportRecipientsRequired: '已启用报告邮件,但未配置任何收件人',
invalidRecipients: '存在不合法的收件人邮箱',
rateLimitRange: '每小时限额必须为 ≥ 0 的数字',
batchWindowRange: '合并窗口必须在 0 到 86400 秒之间',
cronRequired: '启用定时任务时必须填写 Cron 表达式',
cronFormat: 'Cron 表达式格式可能不正确(至少应包含 5 段)',
digestMinCountRange: '错误摘要最小数量必须为 ≥ 0 的数字',
accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间'
}
},
settings: {
title: '运维监控设置',
loadFailed: '加载设置失败',
saveSuccess: '运维监控设置保存成功',
saveFailed: '保存设置失败',
dataCollection: '数据采集',
evaluationInterval: '评估间隔(秒)',
evaluationIntervalHint: '检测任务的执行频率,建议保持默认',
alertConfig: '预警配置',
enableAlert: '开启预警',
alertRecipients: '预警接收邮箱',
emailPlaceholder: '输入邮箱地址',
recipientsHint: '若为空,系统将使用第一个管理员邮箱作为默认收件人',
minSeverity: '最低级别',
reportConfig: '评估报告配置',
enableReport: '开启评估报告',
reportRecipients: '评估报告接收邮箱',
dailySummary: '每日摘要',
weeklySummary: '每周摘要',
advancedSettings: '高级设置',
dataRetention: '数据保留策略',
enableCleanup: '启用数据清理',
cleanupSchedule: '清理计划(Cron)',
cleanupScheduleHint: '例如:0 2 * * * 表示每天凌晨2点',
errorLogRetentionDays: '错误日志保留天数',
minuteMetricsRetentionDays: '分钟指标保留天数',
hourlyMetricsRetentionDays: '小时指标保留天数',
retentionDaysHint: '建议保留7-90天,过长会占用存储空间',
aggregation: '预聚合任务',
enableAggregation: '启用预聚合任务',
aggregationHint: '预聚合可提升长时间窗口查询性能',
validation: {
title: '请先修正以下问题',
retentionDaysRange: '保留天数必须在1-365天之间'
}
},
concurrency: {
title: '并发 / 排队',
byPlatform: '按平台',
byGroup: '按分组',
byAccount: '按账号',
totalRows: '共 {count} 项',
disabledHint: '已在设置中关闭实时监控。',
empty: '暂无数据',
queued: '队列 {count}',
rateLimited: '限流 {count}',
errorAccounts: '异常 {count}',
loadFailed: '加载并发数据失败'
},
realtime: {
title: '实时信息',
connected: '实时已连接',
connecting: '实时连接中',
reconnecting: '实时重连中',
offline: '实时离线',
closed: '实时已关闭',
reconnectIn: '重连 {seconds}s'
},
queryMode: {
auto: 'Auto(自动)',
raw: 'Raw(不聚合)',
preagg: 'Preagg(聚合)'
},
accountAvailability: {
available: '可用',
unavailable: '不可用',
accountError: '异常'
},
tooltips: {
totalRequests: '当前时间窗口内的总请求数和Token消耗量。',
throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。',
latencyHistogram: '成功请求的延迟分布(毫秒)。',
errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。',
errorDistribution: '按状态码统计的错误分布。',
upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。',
goroutines:
'Go 运行时的协程数量(轻量级线程)。没有绝对“安全值”,建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列/延迟上升时,优先排查阻塞/泄漏。',
cpu: 'CPU 使用率,显示系统处理器的负载情况。',
memory: '内存使用率,包括已使用和总可用内存。',
db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。',
redis: 'Redis 连接池状态,显示活跃和空闲的连接数。',
jobs: '后台任务执行状态,包括最近运行时间、成功时间和错误信息。',
qps: '每秒查询数(QPS)和每秒Token数(TPS),实时显示系统吞吐量。',
tokens: '当前时间窗口内处理的总Token数量。',
sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。',
errors: '错误统计,包括总错误数、错误率和上游错误率。',
latency: '请求延迟统计,包括 p50、p90、p95、p99 等百分位数。',
ttft: '首Token延迟(Time To First Token),衡量流式响应的首字节返回速度。',
health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。'
},
charts: {
emptyRequest: '该时间窗口内暂无请求。',
emptyError: '该时间窗口内暂无错误。',
resetZoom: '重置',
resetZoomHint: '重置缩放(若启用)',
downloadChart: '下载',
downloadChartHint: '下载图表图片'
}
},
// Settings
settings: {
title: '系统设置',
......@@ -2083,6 +2658,22 @@ export default {
sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址'
},
opsMonitoring: {
title: '运维监控',
description: '启用运维监控模块,用于排障与健康可视化',
disabled: '运维监控已关闭',
enabled: '启用运维监控',
enabledHint: '启用运维监控模块(仅管理员可见)',
realtimeEnabled: '启用实时监控',
realtimeEnabledHint: '启用实时请求速率和指标推送(WebSocket)',
queryMode: '默认查询模式',
queryModeHint: '运维监控默认查询模式(自动/原始/预聚合)',
queryModeAuto: '自动(推荐)',
queryModeRaw: '原始(最准确,但较慢)',
queryModePreagg: '预聚合(最快,需预聚合)',
metricsInterval: '采集频率(秒)',
metricsIntervalHint: '系统/请求指标采集频率(60-3600 秒)'
},
adminApiKey: {
title: '管理员 API Key',
description: '用于外部系统集成的全局 API Key,拥有完整的管理员权限',
......
......@@ -173,6 +173,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.dashboard.description'
}
},
{
path: '/admin/ops',
name: 'AdminOps',
component: () => import('@/views/admin/ops/OpsDashboard.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Ops Monitoring',
titleKey: 'admin.ops.title',
descriptionKey: 'admin.ops.description'
}
},
{
path: '/admin/users',
name: 'AdminUsers',
......
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminAPI } from '@/api'
export const useAdminSettingsStore = defineStore('adminSettings', () => {
const loaded = ref(false)
const loading = ref(false)
const readCachedBool = (key: string, defaultValue: boolean): boolean => {
try {
const raw = localStorage.getItem(key)
if (raw === 'true') return true
if (raw === 'false') return false
} catch {
// ignore localStorage failures
}
return defaultValue
}
const writeCachedBool = (key: string, value: boolean) => {
try {
localStorage.setItem(key, value ? 'true' : 'false')
} catch {
// ignore localStorage failures
}
}
const readCachedString = (key: string, defaultValue: string): string => {
try {
const raw = localStorage.getItem(key)
if (typeof raw === 'string' && raw.length > 0) return raw
} catch {
// ignore localStorage failures
}
return defaultValue
}
const writeCachedString = (key: string, value: string) => {
try {
localStorage.setItem(key, value)
} catch {
// ignore localStorage failures
}
}
// Default open, but honor cached value to reduce UI flicker on first paint.
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
async function fetch(force = false): Promise<void> {
if (loaded.value && !force) return
if (loading.value) return
loading.value = true
try {
const settings = await adminAPI.settings.getSettings()
opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
opsRealtimeMonitoringEnabled.value = settings.ops_realtime_monitoring_enabled ?? true
writeCachedBool('ops_realtime_monitoring_enabled_cached', opsRealtimeMonitoringEnabled.value)
opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
loaded.value = true
} catch (err) {
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
loaded.value = true
console.error('[adminSettings] Failed to fetch settings:', err)
} finally {
loading.value = false
}
}
function setOpsMonitoringEnabledLocal(value: boolean) {
opsMonitoringEnabled.value = value
writeCachedBool('ops_monitoring_enabled_cached', value)
loaded.value = true
}
function setOpsRealtimeMonitoringEnabledLocal(value: boolean) {
opsRealtimeMonitoringEnabled.value = value
writeCachedBool('ops_realtime_monitoring_enabled_cached', value)
loaded.value = true
}
function setOpsQueryModeDefaultLocal(value: string) {
opsQueryModeDefault.value = value || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
loaded.value = true
}
// Keep UI consistent if we learn that ops is disabled via feature-gated 404s.
// (event is dispatched from the axios interceptor)
let eventHandlerCleanup: (() => void) | null = null
function initializeEventListeners() {
if (eventHandlerCleanup) return
try {
const handler = () => {
setOpsMonitoringEnabledLocal(false)
}
window.addEventListener('ops-monitoring-disabled', handler)
eventHandlerCleanup = () => {
window.removeEventListener('ops-monitoring-disabled', handler)
}
} catch {
// ignore window access failures (SSR)
}
}
if (typeof window !== 'undefined') {
initializeEventListeners()
}
return {
loaded,
loading,
opsMonitoringEnabled,
opsRealtimeMonitoringEnabled,
opsQueryModeDefault,
fetch,
setOpsMonitoringEnabledLocal,
setOpsRealtimeMonitoringEnabledLocal,
setOpsQueryModeDefaultLocal
}
})
......@@ -5,6 +5,7 @@
export { useAuthStore } from './auth'
export { useAppStore } from './app'
export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding'
......
......@@ -871,17 +871,29 @@ const form = reactive<SettingsForm>({
turnstile_site_key: '',
turnstile_secret_key: '',
turnstile_secret_key_configured: false,
// LinuxDo Connect OAuth(终端用户登录
// LinuxDo Connect OAuth 登录
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: '',
linuxdo_connect_client_secret: '',
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: '',
// Model fallback
enable_model_fallback: false,
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
fallback_model_openai: 'gpt-4o',
fallback_model_gemini: 'gemini-2.5-pro',
fallback_model_antigravity: 'gemini-2.5-pro',
// Identity patch (Claude -> Gemini)
enable_identity_patch: true,
identity_patch_prompt: ''
identity_patch_prompt: '',
// Ops monitoring (vNext)
ops_monitoring_enabled: true,
ops_realtime_monitoring_enabled: true,
ops_query_mode_default: 'auto',
ops_metrics_interval_seconds: 60
})
// LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
const origin =
......@@ -980,7 +992,14 @@ async function saveSettings() {
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
enable_model_fallback: form.enable_model_fallback,
fallback_model_anthropic: form.fallback_model_anthropic,
fallback_model_openai: form.fallback_model_openai,
fallback_model_gemini: form.fallback_model_gemini,
fallback_model_antigravity: form.fallback_model_antigravity,
enable_identity_patch: form.enable_identity_patch,
identity_patch_prompt: form.identity_patch_prompt
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
......
<template>
<AppLayout>
<div class="space-y-6 pb-12">
<div
v-if="errorMessage"
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
{{ errorMessage }}
</div>
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" />
<OpsDashboardHeader
v-else-if="opsEnabled"
:overview="overview"
:ws-status="wsStatus"
:ws-reconnect-in-ms="wsReconnectInMs"
:ws-has-data="wsHasData"
:real-time-qps="realTimeQPS"
:real-time-tps="realTimeTPS"
:platform="platform"
:group-id="groupId"
:time-range="timeRange"
:query-mode="queryMode"
:loading="loading"
:last-updated="lastUpdated"
@update:time-range="onTimeRangeChange"
@update:platform="onPlatformChange"
@update:group="onGroupChange"
@update:query-mode="onQueryModeChange"
@refresh="fetchData"
@open-request-details="handleOpenRequestDetails"
@open-error-details="openErrorDetails"
@open-settings="showSettingsDialog = true"
@open-alert-rules="showAlertRulesCard = true"
/>
<!-- Row: Concurrency + Throughput -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-1 min-h-[360px]">
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" />
</div>
<div class="lg:col-span-2 min-h-[360px]">
<OpsThroughputTrendChart
:points="throughputTrend?.points ?? []"
:by-platform="throughputTrend?.by_platform ?? []"
:top-groups="throughputTrend?.top_groups ?? []"
:loading="loadingTrend"
:time-range="timeRange"
@select-platform="handleThroughputSelectPlatform"
@select-group="handleThroughputSelectGroup"
@open-details="handleOpenRequestDetails"
/>
</div>
</div>
<!-- Row: Visual Analysis (baseline 3-up grid) -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 md:grid-cols-3">
<OpsLatencyChart :latency-data="latencyHistogram" :loading="loadingLatency" />
<OpsErrorDistributionChart
:data="errorDistribution"
:loading="loadingErrorDistribution"
@open-details="openErrorDetails('request')"
/>
<OpsErrorTrendChart
:points="errorTrend?.points ?? []"
:loading="loadingErrorTrend"
:time-range="timeRange"
@open-request-errors="openErrorDetails('request')"
@open-upstream-errors="openErrorDetails('upstream')"
/>
</div>
<!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
<!-- Settings Dialog -->
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="fetchData" />
<!-- Alert Rules Dialog -->
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
<OpsAlertRulesCard />
</BaseDialog>
<OpsErrorDetailsModal
:show="showErrorDetails"
:time-range="timeRange"
:platform="platform"
:group-id="groupId"
:error-type="errorDetailsType"
@update:show="showErrorDetails = $event"
@openErrorDetail="openError"
/>
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
<OpsRequestDetailsModal
v-model="showRequestDetails"
:time-range="timeRange"
:preset="requestDetailsPreset"
:platform="platform"
:group-id="groupId"
@openErrorDetail="openError"
/>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import {
opsAPI,
OPS_WS_CLOSE_CODES,
type OpsWSStatus,
type OpsDashboardOverview,
type OpsErrorDistributionResponse,
type OpsErrorTrendResponse,
type OpsLatencyHistogramResponse,
type OpsThroughputTrendResponse
} from '@/api/admin/ops'
import { useAdminSettingsStore, useAppStore } from '@/stores'
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
import OpsDashboardSkeleton from './components/OpsDashboardSkeleton.vue'
import OpsConcurrencyCard from './components/OpsConcurrencyCard.vue'
import OpsErrorDetailModal from './components/OpsErrorDetailModal.vue'
import OpsErrorDistributionChart from './components/OpsErrorDistributionChart.vue'
import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
import OpsLatencyChart from './components/OpsLatencyChart.vue'
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const adminSettingsStore = useAdminSettingsStore()
const { t } = useI18n()
const opsEnabled = computed(() => adminSettingsStore.opsMonitoringEnabled)
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h'
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h'])
type QueryMode = 'auto' | 'raw' | 'preagg'
const allowedQueryModes = new Set<QueryMode>(['auto', 'raw', 'preagg'])
const loading = ref(true)
const hasLoadedOnce = ref(false)
const errorMessage = ref('')
const lastUpdated = ref<Date | null>(new Date())
const timeRange = ref<TimeRange>('1h')
const platform = ref<string>('')
const groupId = ref<number | null>(null)
const queryMode = ref<QueryMode>('auto')
const QUERY_KEYS = {
timeRange: 'tr',
platform: 'platform',
groupId: 'group_id',
queryMode: 'mode'
} as const
const isApplyingRouteQuery = ref(false)
const isSyncingRouteQuery = ref(false)
// WebSocket for realtime QPS/TPS
const realTimeQPS = ref(0)
const realTimeTPS = ref(0)
const wsStatus = ref<OpsWSStatus>('closed')
const wsReconnectInMs = ref<number | null>(null)
const wsHasData = ref(false)
let unsubscribeQPS: (() => void) | null = null
let dashboardFetchController: AbortController | null = null
let dashboardFetchSeq = 0
function isCanceledRequest(err: unknown): boolean {
return (
!!err &&
typeof err === 'object' &&
'code' in err &&
(err as Record<string, unknown>).code === 'ERR_CANCELED'
)
}
function abortDashboardFetch() {
if (dashboardFetchController) {
dashboardFetchController.abort()
dashboardFetchController = null
}
}
function stopQPSSubscription(options?: { resetMetrics?: boolean }) {
wsStatus.value = 'closed'
wsReconnectInMs.value = null
if (unsubscribeQPS) unsubscribeQPS()
unsubscribeQPS = null
if (options?.resetMetrics) {
realTimeQPS.value = 0
realTimeTPS.value = 0
wsHasData.value = false
}
}
function startQPSSubscription() {
stopQPSSubscription()
unsubscribeQPS = opsAPI.subscribeQPS(
(payload) => {
if (payload && typeof payload === 'object' && payload.type === 'qps_update' && payload.data) {
realTimeQPS.value = payload.data.qps || 0
realTimeTPS.value = payload.data.tps || 0
wsHasData.value = true
}
},
{
onStatusChange: (status) => {
wsStatus.value = status
if (status === 'connected') wsReconnectInMs.value = null
},
onReconnectScheduled: ({ delayMs }) => {
wsReconnectInMs.value = delayMs
},
onFatalClose: (event) => {
// Server-side feature flag says realtime is disabled; keep UI consistent and avoid reconnect loops.
if (event && event.code === OPS_WS_CLOSE_CODES.REALTIME_DISABLED) {
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
stopQPSSubscription({ resetMetrics: true })
}
},
// QPS updates may be sparse in idle periods; keep the timeout conservative.
staleTimeoutMs: 180_000
}
)
}
const readQueryString = (key: string): string => {
const value = route.query[key]
if (typeof value === 'string') return value
if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
return ''
}
const readQueryNumber = (key: string): number | null => {
const raw = readQueryString(key)
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : null
}
const applyRouteQueryToState = () => {
const nextTimeRange = readQueryString(QUERY_KEYS.timeRange)
if (nextTimeRange && allowedTimeRanges.has(nextTimeRange as TimeRange)) {
timeRange.value = nextTimeRange as TimeRange
}
platform.value = readQueryString(QUERY_KEYS.platform) || ''
const groupIdRaw = readQueryNumber(QUERY_KEYS.groupId)
groupId.value = typeof groupIdRaw === 'number' && groupIdRaw > 0 ? groupIdRaw : null
const nextMode = readQueryString(QUERY_KEYS.queryMode)
if (nextMode && allowedQueryModes.has(nextMode as QueryMode)) {
queryMode.value = nextMode as QueryMode
} else {
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
}
}
applyRouteQueryToState()
const buildQueryFromState = () => {
const next: Record<string, any> = { ...route.query }
Object.values(QUERY_KEYS).forEach((k) => {
delete next[k]
})
if (timeRange.value !== '1h') next[QUERY_KEYS.timeRange] = timeRange.value
if (platform.value) next[QUERY_KEYS.platform] = platform.value
if (typeof groupId.value === 'number' && groupId.value > 0) next[QUERY_KEYS.groupId] = String(groupId.value)
if (queryMode.value !== 'auto') next[QUERY_KEYS.queryMode] = queryMode.value
return next
}
const syncQueryToRoute = useDebounceFn(async () => {
if (isApplyingRouteQuery.value) return
const nextQuery = buildQueryFromState()
const curr = route.query as Record<string, any>
const nextKeys = Object.keys(nextQuery)
const currKeys = Object.keys(curr)
const sameLength = nextKeys.length === currKeys.length
const sameValues = sameLength && nextKeys.every((k) => String(curr[k] ?? '') === String(nextQuery[k] ?? ''))
if (sameValues) return
try {
isSyncingRouteQuery.value = true
await router.replace({ query: nextQuery })
} finally {
isSyncingRouteQuery.value = false
}
}, 250)
const overview = ref<OpsDashboardOverview | null>(null)
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingTrend = ref(false)
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
const loadingLatency = ref(false)
const errorTrend = ref<OpsErrorTrendResponse | null>(null)
const loadingErrorTrend = ref(false)
const errorDistribution = ref<OpsErrorDistributionResponse | null>(null)
const loadingErrorDistribution = ref(false)
const selectedErrorId = ref<number | null>(null)
const showErrorModal = ref(false)
const showErrorDetails = ref(false)
const errorDetailsType = ref<'request' | 'upstream'>('request')
const showRequestDetails = ref(false)
const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
title: '',
kind: 'all',
sort: 'created_at_desc'
})
const showSettingsDialog = ref(false)
const showAlertRulesCard = ref(false)
function handleThroughputSelectPlatform(nextPlatform: string) {
platform.value = nextPlatform || ''
groupId.value = null
}
function handleThroughputSelectGroup(nextGroupId: number) {
const id = Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null
groupId.value = id
}
function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
const basePreset: OpsRequestDetailsPreset = {
title: t('admin.ops.requestDetails.title'),
kind: 'all',
sort: 'created_at_desc'
}
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title
showRequestDetails.value = true
}
function openErrorDetails(kind: 'request' | 'upstream') {
errorDetailsType.value = kind
showErrorDetails.value = true
}
function onTimeRangeChange(v: string | number | boolean | null) {
if (typeof v !== 'string') return
if (!allowedTimeRanges.has(v as TimeRange)) return
timeRange.value = v as TimeRange
}
function onPlatformChange(v: string | number | boolean | null) {
platform.value = typeof v === 'string' ? v : ''
}
function onGroupChange(v: string | number | boolean | null) {
if (v === null) {
groupId.value = null
return
}
if (typeof v === 'number') {
groupId.value = v > 0 ? v : null
return
}
if (typeof v === 'string') {
const n = Number.parseInt(v, 10)
groupId.value = Number.isFinite(n) && n > 0 ? n : null
}
}
function onQueryModeChange(v: string | number | boolean | null) {
if (typeof v !== 'string') return
if (!allowedQueryModes.has(v as QueryMode)) return
queryMode.value = v as QueryMode
}
function openError(id: number) {
selectedErrorId.value = id
showErrorModal.value = true
}
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
try {
const data = await opsAPI.getDashboardOverview(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
if (fetchSeq !== dashboardFetchSeq) return
overview.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
overview.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadOverview'))
}
}
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
try {
const data = await opsAPI.getThroughputTrend(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
if (fetchSeq !== dashboardFetchSeq) return
throughputTrend.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
throughputTrend.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadThroughputTrend'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingTrend.value = false
}
}
}
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingLatency.value = true
try {
const data = await opsAPI.getLatencyHistogram(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
if (fetchSeq !== dashboardFetchSeq) return
latencyHistogram.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
latencyHistogram.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadLatencyHistogram'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingLatency.value = false
}
}
}
async function refreshErrorTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingErrorTrend.value = true
try {
const data = await opsAPI.getErrorTrend(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
if (fetchSeq !== dashboardFetchSeq) return
errorTrend.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
errorTrend.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorTrend'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingErrorTrend.value = false
}
}
}
async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingErrorDistribution.value = true
try {
const data = await opsAPI.getErrorDistribution(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
if (fetchSeq !== dashboardFetchSeq) return
errorDistribution.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
errorDistribution.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDistribution'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingErrorDistribution.value = false
}
}
}
function isOpsDisabledError(err: unknown): boolean {
return (
!!err &&
typeof err === 'object' &&
'code' in err &&
typeof (err as Record<string, unknown>).code === 'string' &&
(err as Record<string, unknown>).code === 'OPS_DISABLED'
)
}
async function fetchData() {
if (!opsEnabled.value) return
abortDashboardFetch()
dashboardFetchSeq += 1
const fetchSeq = dashboardFetchSeq
dashboardFetchController = new AbortController()
loading.value = true
errorMessage.value = ''
try {
await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
])
if (fetchSeq !== dashboardFetchSeq) return
lastUpdated.value = new Date()
} catch (err) {
if (!isOpsDisabledError(err)) {
console.error('[ops] failed to fetch dashboard data', err)
errorMessage.value = t('admin.ops.failedToLoadData')
}
} finally {
if (fetchSeq === dashboardFetchSeq) {
loading.value = false
hasLoadedOnce.value = true
}
}
}
watch(
() => [timeRange.value, platform.value, groupId.value, queryMode.value] as const,
() => {
if (isApplyingRouteQuery.value) return
if (opsEnabled.value) {
fetchData()
}
syncQueryToRoute()
}
)
watch(
() => route.query,
() => {
if (isSyncingRouteQuery.value) return
const prevTimeRange = timeRange.value
const prevPlatform = platform.value
const prevGroupId = groupId.value
isApplyingRouteQuery.value = true
applyRouteQueryToState()
isApplyingRouteQuery.value = false
const changed =
prevTimeRange !== timeRange.value || prevPlatform !== platform.value || prevGroupId !== groupId.value
if (changed) {
if (opsEnabled.value) {
fetchData()
}
}
}
)
onMounted(async () => {
await adminSettingsStore.fetch()
if (!adminSettingsStore.opsMonitoringEnabled) {
await router.replace('/admin/settings')
return
}
if (adminSettingsStore.opsRealtimeMonitoringEnabled) {
startQPSSubscription()
} else {
stopQPSSubscription({ resetMetrics: true })
}
if (opsEnabled.value) {
await fetchData()
}
})
onUnmounted(() => {
stopQPSSubscription()
abortDashboardFetch()
})
watch(
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
(enabled) => {
if (!opsEnabled.value) return
if (enabled) {
startQPSSubscription()
} else {
stopQPSSubscription({ resetMetrics: true })
}
}
)
</script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import Select from '@/components/common/Select.vue'
import { opsAPI } from '@/api/admin/ops'
import type { AlertEvent } from '../types'
import { formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const events = ref<AlertEvent[]>([])
const limit = ref(100)
const limitOptions = computed(() => [
{ value: 50, label: '50' },
{ value: 100, label: '100' },
{ value: 200, label: '200' }
])
async function load() {
loading.value = true
try {
events.value = await opsAPI.listAlertEvents(limit.value)
} catch (err: any) {
console.error('[OpsAlertEventsCard] Failed to load alert events', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
events.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
load()
})
watch(limit, () => {
load()
})
function severityBadgeClass(severity: string | undefined): string {
const s = String(severity || '').trim().toLowerCase()
if (s === 'p0' || s === 'critical') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
if (s === 'p1' || s === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
if (s === 'p2' || s === 'info') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
if (s === 'p3') return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
}
function statusBadgeClass(status: string | undefined): string {
const s = String(status || '').trim().toLowerCase()
if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30'
if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'
}
const empty = computed(() => events.value.length === 0 && !loading.value)
</script>
<template>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.description') }}</p>
</div>
<div class="flex items-center gap-2">
<Select :model-value="limit" :options="limitOptions" class="w-[88px]" @change="limit = Number($event || 100)" />
<button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
@click="load"
>
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.refresh') }}
</button>
</div>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('admin.ops.alertEvents.loading') }}
</div>
<div v-else-if="empty" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
{{ t('admin.ops.alertEvents.empty') }}
</div>
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div class="max-h-[600px] overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
<tr>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.time') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.status') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.severity') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.title') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.metric') }}
</th>
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertEvents.table.email') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="row in events" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ formatDateTime(row.fired_at || row.created_at) }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
{{ String(row.status || '-').toUpperCase() }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
{{ row.severity || '-' }}
</span>
</td>
<td class="min-w-[280px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
<div class="font-semibold">{{ row.title || '-' }}</div>
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
{{ row.description }}
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
<span v-if="typeof row.metric_value === 'number' && typeof row.threshold_value === 'number'">
{{ row.metric_value.toFixed(2) }} / {{ row.threshold_value.toFixed(2) }}
</span>
<span v-else>-</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset"
:class="row.email_sent ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' : 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'"
>
{{ row.email_sent ? t('common.enabled') : t('common.disabled') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import { adminAPI } from '@/api'
import { opsAPI } from '@/api/admin/ops'
import type { AlertRule, MetricType, Operator } from '../types'
import type { OpsSeverity } from '@/api/admin/ops'
import { formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const rules = ref<AlertRule[]>([])
async function load() {
loading.value = true
try {
rules.value = await opsAPI.listAlertRules()
} catch (err: any) {
console.error('[OpsAlertRulesCard] Failed to load rules', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.loadFailed'))
rules.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
load()
loadGroups()
})
const sortedRules = computed(() => {
return [...rules.value].sort((a, b) => (b.id || 0) - (a.id || 0))
})
const showEditor = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const draft = ref<AlertRule | null>(null)
type MetricGroup = 'system' | 'group' | 'account'
interface MetricDefinition {
type: MetricType
group: MetricGroup
label: string
description: string
recommendedOperator: Operator
recommendedThreshold: number
unit?: string
}
const groupMetricTypes = new Set<MetricType>([
'group_available_accounts',
'group_available_ratio',
'group_rate_limit_ratio'
])
function parsePositiveInt(value: unknown): number | null {
if (value == null) return null
if (typeof value === 'boolean') return null
const n = typeof value === 'number' ? value : Number.parseInt(String(value), 10)
return Number.isFinite(n) && n > 0 ? n : null
}
const groupOptionsBase = ref<SelectOption[]>([])
async function loadGroups() {
try {
const list = await adminAPI.groups.getAll()
groupOptionsBase.value = list.map((g) => ({ value: g.id, label: g.name }))
} catch (err) {
console.error('[OpsAlertRulesCard] Failed to load groups', err)
groupOptionsBase.value = []
}
}
const isGroupMetricSelected = computed(() => {
const metricType = draft.value?.metric_type
return metricType ? groupMetricTypes.has(metricType) : false
})
const draftGroupId = computed<number | null>({
get() {
return parsePositiveInt(draft.value?.filters?.group_id)
},
set(value) {
if (!draft.value) return
if (value == null) {
if (!draft.value.filters) return
delete draft.value.filters.group_id
if (Object.keys(draft.value.filters).length === 0) {
delete draft.value.filters
}
return
}
if (!draft.value.filters) draft.value.filters = {}
draft.value.filters.group_id = value
}
})
const groupOptions = computed<SelectOption[]>(() => {
if (isGroupMetricSelected.value) return groupOptionsBase.value
return [{ value: null, label: t('admin.ops.alertRules.form.allGroups') }, ...groupOptionsBase.value]
})
const metricDefinitions = computed(() => {
return [
// System-level metrics
{
type: 'success_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.successRate'),
description: t('admin.ops.alertRules.metricDescriptions.successRate'),
recommendedOperator: '<',
recommendedThreshold: 99,
unit: '%'
},
{
type: 'error_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.errorRate'),
description: t('admin.ops.alertRules.metricDescriptions.errorRate'),
recommendedOperator: '>',
recommendedThreshold: 1,
unit: '%'
},
{
type: 'upstream_error_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.upstreamErrorRate'),
description: t('admin.ops.alertRules.metricDescriptions.upstreamErrorRate'),
recommendedOperator: '>',
recommendedThreshold: 1,
unit: '%'
},
{
type: 'p95_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p95'),
description: t('admin.ops.alertRules.metricDescriptions.p95'),
recommendedOperator: '>',
recommendedThreshold: 1000,
unit: 'ms'
},
{
type: 'p99_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p99'),
description: t('admin.ops.alertRules.metricDescriptions.p99'),
recommendedOperator: '>',
recommendedThreshold: 2000,
unit: 'ms'
},
{
type: 'cpu_usage_percent',
group: 'system',
label: t('admin.ops.alertRules.metrics.cpu'),
description: t('admin.ops.alertRules.metricDescriptions.cpu'),
recommendedOperator: '>',
recommendedThreshold: 80,
unit: '%'
},
{
type: 'memory_usage_percent',
group: 'system',
label: t('admin.ops.alertRules.metrics.memory'),
description: t('admin.ops.alertRules.metricDescriptions.memory'),
recommendedOperator: '>',
recommendedThreshold: 80,
unit: '%'
},
{
type: 'concurrency_queue_depth',
group: 'system',
label: t('admin.ops.alertRules.metrics.queueDepth'),
description: t('admin.ops.alertRules.metricDescriptions.queueDepth'),
recommendedOperator: '>',
recommendedThreshold: 10
},
// Group-level metrics (requires group_id filter)
{
type: 'group_available_accounts',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupAvailableAccounts'),
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableAccounts'),
recommendedOperator: '<',
recommendedThreshold: 1
},
{
type: 'group_available_ratio',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupAvailableRatio'),
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableRatio'),
recommendedOperator: '<',
recommendedThreshold: 50,
unit: '%'
},
{
type: 'group_rate_limit_ratio',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupRateLimitRatio'),
description: t('admin.ops.alertRules.metricDescriptions.groupRateLimitRatio'),
recommendedOperator: '>',
recommendedThreshold: 10,
unit: '%'
},
// Account-level metrics
{
type: 'account_rate_limited_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountRateLimitedCount'),
description: t('admin.ops.alertRules.metricDescriptions.accountRateLimitedCount'),
recommendedOperator: '>',
recommendedThreshold: 0
},
{
type: 'account_error_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountErrorCount'),
description: t('admin.ops.alertRules.metricDescriptions.accountErrorCount'),
recommendedOperator: '>',
recommendedThreshold: 0
},
{
type: 'account_error_ratio',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountErrorRatio'),
description: t('admin.ops.alertRules.metricDescriptions.accountErrorRatio'),
recommendedOperator: '>',
recommendedThreshold: 5,
unit: '%'
},
{
type: 'overload_account_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.overloadAccountCount'),
description: t('admin.ops.alertRules.metricDescriptions.overloadAccountCount'),
recommendedOperator: '>',
recommendedThreshold: 0
}
] satisfies MetricDefinition[]
})
const selectedMetricDefinition = computed(() => {
const metricType = draft.value?.metric_type
if (!metricType) return null
return metricDefinitions.value.find((m) => m.type === metricType) ?? null
})
const metricOptions = computed(() => {
const buildGroup = (group: MetricGroup): SelectOption[] => {
const items = metricDefinitions.value.filter((m) => m.group === group)
if (items.length === 0) return []
const headerValue = `__group__${group}`
return [
{
value: headerValue,
label: t(`admin.ops.alertRules.metricGroups.${group}`),
disabled: true,
kind: 'group'
},
...items.map((m) => ({ value: m.type, label: m.label }))
]
}
return [...buildGroup('system'), ...buildGroup('group'), ...buildGroup('account')]
})
const operatorOptions = computed(() => {
const ops: Operator[] = ['>', '>=', '<', '<=', '==', '!=']
return ops.map((o) => ({ value: o, label: o }))
})
const severityOptions = computed(() => {
const sev: OpsSeverity[] = ['P0', 'P1', 'P2', 'P3']
return sev.map((s) => ({ value: s, label: s }))
})
const windowOptions = computed(() => {
const windows = [1, 5, 60]
return windows.map((m) => ({ value: m, label: `${m}m` }))
})
function newRuleDraft(): AlertRule {
return {
name: '',
description: '',
enabled: true,
metric_type: 'error_rate',
operator: '>',
threshold: 1,
window_minutes: 1,
sustained_minutes: 2,
severity: 'P1',
cooldown_minutes: 10,
notify_email: true
}
}
function openCreate() {
editingId.value = null
draft.value = newRuleDraft()
showEditor.value = true
}
function openEdit(rule: AlertRule) {
editingId.value = rule.id ?? null
draft.value = JSON.parse(JSON.stringify(rule))
showEditor.value = true
}
const editorValidation = computed(() => {
const errors: string[] = []
const r = draft.value
if (!r) return { valid: true, errors }
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
if (groupMetricTypes.has(r.metric_type) && !parsePositiveInt(r.filters?.group_id)) {
errors.push(t('admin.ops.alertRules.validation.groupIdRequired'))
}
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
if (!(typeof r.window_minutes === 'number' && Number.isFinite(r.window_minutes) && [1, 5, 60].includes(r.window_minutes))) {
errors.push(t('admin.ops.alertRules.validation.windowRange'))
}
if (!(typeof r.sustained_minutes === 'number' && Number.isFinite(r.sustained_minutes) && r.sustained_minutes >= 1 && r.sustained_minutes <= 1440)) {
errors.push(t('admin.ops.alertRules.validation.sustainedRange'))
}
if (!(typeof r.cooldown_minutes === 'number' && Number.isFinite(r.cooldown_minutes) && r.cooldown_minutes >= 0 && r.cooldown_minutes <= 1440)) {
errors.push(t('admin.ops.alertRules.validation.cooldownRange'))
}
return { valid: errors.length === 0, errors }
})
async function save() {
if (!draft.value) return
if (!editorValidation.value.valid) {
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.alertRules.validation.invalid'))
return
}
saving.value = true
try {
if (editingId.value) {
await opsAPI.updateAlertRule(editingId.value, draft.value)
} else {
await opsAPI.createAlertRule(draft.value)
}
showEditor.value = false
draft.value = null
editingId.value = null
await load()
appStore.showSuccess(t('admin.ops.alertRules.saveSuccess'))
} catch (err: any) {
console.error('[OpsAlertRulesCard] Failed to save rule', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.saveFailed'))
} finally {
saving.value = false
}
}
const showDeleteConfirm = ref(false)
const pendingDelete = ref<AlertRule | null>(null)
function requestDelete(rule: AlertRule) {
pendingDelete.value = rule
showDeleteConfirm.value = true
}
async function confirmDelete() {
if (!pendingDelete.value?.id) return
try {
await opsAPI.deleteAlertRule(pendingDelete.value.id)
showDeleteConfirm.value = false
pendingDelete.value = null
await load()
appStore.showSuccess(t('admin.ops.alertRules.deleteSuccess'))
} catch (err: any) {
console.error('[OpsAlertRulesCard] Failed to delete rule', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.deleteFailed'))
}
}
function cancelDelete() {
showDeleteConfirm.value = false
pendingDelete.value = null
}
</script>
<template>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertRules.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertRules.description') }}</p>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-sm btn-primary" :disabled="loading" @click="openCreate">
{{ t('admin.ops.alertRules.create') }}
</button>
<button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
@click="load"
>
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.refresh') }}
</button>
</div>
</div>
<div v-if="loading" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.loading') }}
</div>
<div v-else-if="sortedRules.length === 0" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
{{ t('admin.ops.alertRules.empty') }}
</div>
<div v-else class="max-h-[520px] overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div class="max-h-[520px] overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
<tr>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.table.name') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.table.metric') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.table.severity') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.table.enabled') }}
</th>
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.alertRules.table.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="row in sortedRules" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
<td class="px-4 py-3">
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ row.name }}</div>
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
{{ row.description }}
</div>
<div v-if="row.updated_at" class="mt-1 text-[10px] text-gray-400">
{{ formatDateTime(row.updated_at) }}
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
<span class="font-mono">{{ row.metric_type }}</span>
<span class="mx-1 text-gray-400">{{ row.operator }}</span>
<span class="font-mono">{{ row.threshold }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs font-bold text-gray-700 dark:text-gray-200">
{{ row.severity }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
{{ row.enabled ? t('common.enabled') : t('common.disabled') }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
<button class="btn btn-sm btn-secondary" @click="openEdit(row)">{{ t('common.edit') }}</button>
<button class="ml-2 btn btn-sm btn-danger" @click="requestDelete(row)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<BaseDialog
:show="showEditor"
:title="editingId ? t('admin.ops.alertRules.editTitle') : t('admin.ops.alertRules.createTitle')"
width="wide"
@close="showEditor = false"
>
<div class="space-y-4">
<div v-if="!editorValidation.valid" class="rounded-xl bg-red-50 p-4 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
<div class="font-bold">{{ t('admin.ops.alertRules.validation.title') }}</div>
<ul class="mt-1 list-disc pl-5">
<li v-for="e in editorValidation.errors" :key="e">{{ e }}</li>
</ul>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="input-label">{{ t('admin.ops.alertRules.form.name') }}</label>
<input v-model="draft!.name" class="input" type="text" />
</div>
<div class="md:col-span-2">
<label class="input-label">{{ t('admin.ops.alertRules.form.description') }}</label>
<input v-model="draft!.description" class="input" type="text" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
<Select v-model="draft!.metric_type" :options="metricOptions" />
<div v-if="selectedMetricDefinition" class="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<p>{{ selectedMetricDefinition.description }}</p>
<p>
{{
t('admin.ops.alertRules.hints.recommended', {
operator: selectedMetricDefinition.recommendedOperator,
threshold: selectedMetricDefinition.recommendedThreshold,
unit: selectedMetricDefinition.unit || ''
})
}}
</p>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.operator') }}</label>
<Select v-model="draft!.operator" :options="operatorOptions" />
</div>
<div class="md:col-span-2">
<label class="input-label">
{{ t('admin.ops.alertRules.form.groupId') }}
<span v-if="isGroupMetricSelected" class="ml-1 text-red-500">*</span>
</label>
<Select
v-model="draftGroupId"
:options="groupOptions"
searchable
:placeholder="t('admin.ops.alertRules.form.groupPlaceholder')"
:error="isGroupMetricSelected && !draftGroupId"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ isGroupMetricSelected ? t('admin.ops.alertRules.hints.groupRequired') : t('admin.ops.alertRules.hints.groupOptional') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
<input v-model.number="draft!.threshold" class="input" type="number" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.severity') }}</label>
<Select v-model="draft!.severity" :options="severityOptions" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.window') }}</label>
<Select v-model="draft!.window_minutes" :options="windowOptions" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.sustained') }}</label>
<input v-model.number="draft!.sustained_minutes" class="input" type="number" min="1" max="1440" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.cooldown') }}</label>
<input v-model.number="draft!.cooldown_minutes" class="input" type="number" min="0" max="1440" />
</div>
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.enabled') }}</span>
<input v-model="draft!.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</div>
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.notifyEmail') }}</span>
<input v-model="draft!.notify_email" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<button class="btn btn-secondary" :disabled="saving" @click="showEditor = false">
{{ t('common.cancel') }}
</button>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="showDeleteConfirm"
:title="t('admin.ops.alertRules.deleteConfirmTitle')"
:message="t('admin.ops.alertRules.deleteConfirmMessage')"
:confirmText="t('common.delete')"
:cancelText="t('common.cancel')"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useIntervalFn } from '@vueuse/core'
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
interface Props {
platformFilter?: string
groupIdFilter?: number | null
}
const props = withDefaults(defineProps<Props>(), {
platformFilter: '',
groupIdFilter: null
})
const { t } = useI18n()
const loading = ref(false)
const errorMessage = ref('')
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
const realtimeEnabled = computed(() => {
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
})
function safeNumber(n: unknown): number {
return typeof n === 'number' && Number.isFinite(n) ? n : 0
}
// 计算显示维度
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
return 'account'
}
if (props.platformFilter) {
return 'group'
}
return 'platform'
})
// 平台/分组汇总行数据
interface SummaryRow {
key: string
name: string
platform?: string
// 账号统计
total_accounts: number
available_accounts: number
rate_limited_accounts: number
error_accounts: number
// 并发统计
total_concurrency: number
used_concurrency: number
waiting_in_queue: number
// 计算字段
availability_percentage: number
concurrency_percentage: number
}
// 账号详细行数据
interface AccountRow {
key: string
name: string
platform: string
group_name: string
// 并发
current_in_use: number
max_capacity: number
waiting_in_queue: number
load_percentage: number
// 状态
is_available: boolean
is_rate_limited: boolean
rate_limit_remaining_sec?: number
is_overloaded: boolean
overload_remaining_sec?: number
has_error: boolean
error_message?: string
}
// 平台维度汇总
const platformRows = computed((): SummaryRow[] => {
const concStats = concurrency.value?.platform || {}
const availStats = availability.value?.platform || {}
const platforms = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
return Array.from(platforms).map(platform => {
const conc = concStats[platform] || {}
const avail = availStats[platform] || {}
const totalAccounts = safeNumber(avail.total_accounts)
const availableAccounts = safeNumber(avail.available_count)
const totalConcurrency = safeNumber(conc.max_capacity)
const usedConcurrency = safeNumber(conc.current_in_use)
return {
key: platform,
name: platform.toUpperCase(),
total_accounts: totalAccounts,
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
waiting_in_queue: safeNumber(conc.waiting_in_queue),
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
}
}).sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
})
// 分组维度汇总
const groupRows = computed((): SummaryRow[] => {
const concStats = concurrency.value?.group || {}
const availStats = availability.value?.group || {}
const groupIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
const rows = Array.from(groupIds)
.map(gid => {
const conc = concStats[gid] || {}
const avail = availStats[gid] || {}
// 只显示匹配的平台
if (props.platformFilter && conc.platform !== props.platformFilter && avail.platform !== props.platformFilter) {
return null
}
const totalAccounts = safeNumber(avail.total_accounts)
const availableAccounts = safeNumber(avail.available_count)
const totalConcurrency = safeNumber(conc.max_capacity)
const usedConcurrency = safeNumber(conc.current_in_use)
return {
key: gid,
name: String(conc.group_name || avail.group_name || `Group ${gid}`),
platform: String(conc.platform || avail.platform || ''),
total_accounts: totalAccounts,
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
waiting_in_queue: safeNumber(conc.waiting_in_queue),
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
}
})
.filter((row): row is NonNullable<typeof row> => row !== null)
return rows.sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
})
// 账号维度详细
const accountRows = computed((): AccountRow[] => {
const concStats = concurrency.value?.account || {}
const availStats = availability.value?.account || {}
const accountIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
const rows = Array.from(accountIds)
.map(aid => {
const conc = concStats[aid] || {}
const avail = availStats[aid] || {}
// 只显示匹配的分组
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
if (conc.group_id !== props.groupIdFilter && avail.group_id !== props.groupIdFilter) {
return null
}
}
return {
key: aid,
name: String(conc.account_name || avail.account_name || `Account ${aid}`),
platform: String(conc.platform || avail.platform || ''),
group_name: String(conc.group_name || avail.group_name || ''),
current_in_use: safeNumber(conc.current_in_use),
max_capacity: safeNumber(conc.max_capacity),
waiting_in_queue: safeNumber(conc.waiting_in_queue),
load_percentage: safeNumber(conc.load_percentage),
is_available: avail.is_available || false,
is_rate_limited: avail.is_rate_limited || false,
rate_limit_remaining_sec: avail.rate_limit_remaining_sec,
is_overloaded: avail.is_overloaded || false,
overload_remaining_sec: avail.overload_remaining_sec,
has_error: avail.has_error || false,
error_message: avail.error_message || ''
}
})
.filter((row): row is NonNullable<typeof row> => row !== null)
return rows.sort((a, b) => {
// 优先显示异常账号
if (a.has_error !== b.has_error) return a.has_error ? -1 : 1
if (a.is_rate_limited !== b.is_rate_limited) return a.is_rate_limited ? -1 : 1
// 然后按负载排序
return b.load_percentage - a.load_percentage
})
})
// 根据维度选择数据
const displayRows = computed(() => {
if (displayDimension.value === 'account') return accountRows.value
if (displayDimension.value === 'group') return groupRows.value
return platformRows.value
})
const displayTitle = computed(() => {
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
return t('admin.ops.concurrency.byPlatform')
})
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const [concData, availData] = await Promise.all([
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
])
concurrency.value = concData
availability.value = availData
} catch (err: any) {
console.error('[OpsConcurrencyCard] Failed to load data', err)
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
} finally {
loading.value = false
}
}
// 定期刷新(5秒)
const { pause: pauseRefresh, resume: resumeRefresh } = useIntervalFn(
() => {
if (realtimeEnabled.value) {
loadData()
}
},
5000,
{ immediate: false }
)
function getLoadBarClass(loadPct: number): string {
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
if (loadPct >= 50) return 'bg-yellow-500 dark:bg-yellow-600'
return 'bg-green-500 dark:bg-green-600'
}
function getLoadBarStyle(loadPct: number): string {
return `width: ${Math.min(100, Math.max(0, loadPct))}%`
}
function getLoadTextClass(loadPct: number): string {
if (loadPct >= 90) return 'text-red-600 dark:text-red-400'
if (loadPct >= 70) return 'text-orange-600 dark:text-orange-400'
if (loadPct >= 50) return 'text-yellow-600 dark:text-yellow-400'
return 'text-green-600 dark:text-green-400'
}
function formatDuration(seconds: number): string {
if (seconds <= 0) return '0s'
if (seconds < 60) return `${Math.round(seconds)}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
return `${hours}h`
}
onMounted(() => {
loadData()
resumeRefresh()
})
onUnmounted(() => {
pauseRefresh()
})
watch(realtimeEnabled, async (enabled) => {
if (!enabled) {
pauseRefresh()
} else {
resumeRefresh()
await loadData()
}
})
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<!-- 头部 -->
<div class="mb-4 flex shrink-0 items-center justify-between gap-3">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('admin.ops.concurrency.title') }}
</h3>
<button
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="loadData"
>
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="mb-3 shrink-0 rounded-xl bg-red-50 p-2.5 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
{{ errorMessage }}
</div>
<!-- 禁用状态 -->
<div
v-if="!realtimeEnabled"
class="flex flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
{{ t('admin.ops.concurrency.disabledHint') }}
</div>
<!-- 数据展示区域 -->
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<!-- 维度标题栏 -->
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ displayTitle }}
</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.concurrency.totalRows', { count: displayRows.length }) }}
</span>
</div>
<!-- 空状态 -->
<div v-if="displayRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.concurrency.empty') }}
</div>
<!-- 汇总视图平台/分组 -->
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
<!-- 标题行 -->
<div class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
{{ row.name }}
</div>
<span v-if="displayDimension === 'group' && row.platform" class="text-[10px] text-gray-400 dark:text-gray-500">
{{ row.platform.toUpperCase() }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-[10px]">
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.used_concurrency }}/{{ row.total_concurrency }} </span>
<span :class="['font-bold', getLoadTextClass(row.concurrency_percentage)]"> {{ row.concurrency_percentage }}% </span>
</div>
</div>
<!-- 进度条 -->
<div class="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div
class="h-full rounded-full transition-all duration-300"
:class="getLoadBarClass(row.concurrency_percentage)"
:style="getLoadBarStyle(row.concurrency_percentage)"
></div>
</div>
<!-- 统计信息 -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]">
<!-- 账号统计 -->
<div class="flex items-center gap-1">
<svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<span class="text-gray-600 dark:text-gray-300">
<span class="font-bold text-green-600 dark:text-green-400">{{ row.available_accounts }}</span
>/{{ row.total_accounts }}
</span>
<span class="text-gray-400 dark:text-gray-500">{{ row.availability_percentage }}%</span>
</div>
<!-- 限流账号 -->
<span
v-if="row.rate_limited_accounts > 0"
class="rounded-full bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
</span>
<!-- 异常账号 -->
<span
v-if="row.error_accounts > 0"
class="rounded-full bg-red-100 px-1.5 py-0.5 font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{ t('admin.ops.concurrency.errorAccounts', { count: row.error_accounts }) }}
</span>
<!-- 等待队列 -->
<span
v-if="row.waiting_in_queue > 0"
class="rounded-full bg-purple-100 px-1.5 py-0.5 font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
</span>
</div>
</div>
</div>
<!-- 账号详细视图 -->
<div v-else class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as AccountRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
<!-- 账号名称和并发 -->
<div class="mb-1.5 flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
{{ row.name }}
</div>
<div class="mt-0.5 text-[9px] text-gray-400 dark:text-gray-500">
{{ row.group_name }}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- 并发使用 -->
<span class="font-mono text-[11px] font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
<!-- 状态徽章 -->
<span
v-if="row.is_available"
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ t('admin.ops.accountAvailability.available') }}
</span>
<span
v-else-if="row.is_rate_limited"
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formatDuration(row.rate_limit_remaining_sec || 0) }}
</span>
<span
v-else-if="row.is_overloaded"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ formatDuration(row.overload_remaining_sec || 0) }}
</span>
<span
v-else-if="row.has_error"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{{ t('admin.ops.accountAvailability.accountError') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-400"
>
{{ t('admin.ops.accountAvailability.unavailable') }}
</span>
</div>
</div>
<!-- 进度条 -->
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
</div>
<!-- 等待队列 -->
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
}
</style>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { adminAPI } from '@/api'
import type { OpsDashboardOverview, OpsWSStatus } from '@/api/admin/ops'
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
import { formatNumber } from '@/utils/format'
type RealtimeWindow = '1min' | '5min' | '30min' | '1h'
interface Props {
overview?: OpsDashboardOverview | null
wsStatus: OpsWSStatus
wsReconnectInMs?: number | null
wsHasData?: boolean
realTimeQps: number
realTimeTps: number
platform: string
groupId: number | null
timeRange: string
queryMode: string
loading: boolean
lastUpdated: Date | null
}
interface Emits {
(e: 'update:platform', value: string): void
(e: 'update:group', value: number | null): void
(e: 'update:timeRange', value: string): void
(e: 'update:queryMode', value: string): void
(e: 'refresh'): void
(e: 'openRequestDetails', preset?: OpsRequestDetailsPreset): void
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
(e: 'openSettings'): void
(e: 'openAlertRules'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const realtimeWindow = ref<RealtimeWindow>('1min')
const overview = computed(() => props.overview ?? null)
const systemMetrics = computed(() => overview.value?.system_metrics ?? null)
// --- Filters ---
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
const platformOptions = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
])
const timeRangeOptions = computed(() => [
{ value: '5m', label: t('admin.ops.timeRange.5m') },
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '6h', label: t('admin.ops.timeRange.6h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') }
])
const queryModeOptions = computed(() => [
{ value: 'auto', label: t('admin.ops.queryMode.auto') },
{ value: 'raw', label: t('admin.ops.queryMode.raw') },
{ value: 'preagg', label: t('admin.ops.queryMode.preagg') }
])
const groupOptions = computed(() => {
const filtered = props.platform ? groups.value.filter((g) => g.platform === props.platform) : groups.value
return [{ value: null, label: t('common.all') }, ...filtered.map((g) => ({ value: g.id, label: g.name }))]
})
watch(
() => props.platform,
(newPlatform) => {
if (!newPlatform) return
const currentGroup = groups.value.find((g) => g.id === props.groupId)
if (currentGroup && currentGroup.platform !== newPlatform) {
emit('update:group', null)
}
}
)
onMounted(async () => {
try {
const list = await adminAPI.groups.getAll()
groups.value = list.map((g) => ({ id: g.id, name: g.name, platform: g.platform }))
} catch (e) {
console.error('[OpsDashboardHeader] Failed to load groups', e)
groups.value = []
}
})
function handlePlatformChange(val: string | number | boolean | null) {
emit('update:platform', String(val || ''))
}
function handleGroupChange(val: string | number | boolean | null) {
if (val === null || val === '' || typeof val === 'boolean') {
emit('update:group', null)
return
}
const id = typeof val === 'number' ? val : Number.parseInt(String(val), 10)
emit('update:group', Number.isFinite(id) && id > 0 ? id : null)
}
function handleTimeRangeChange(val: string | number | boolean | null) {
emit('update:timeRange', String(val || '1h'))
}
function handleQueryModeChange(val: string | number | boolean | null) {
emit('update:queryMode', String(val || 'auto'))
}
function openDetails(preset?: OpsRequestDetailsPreset) {
emit('openRequestDetails', preset)
}
function openErrorDetails(kind: 'request' | 'upstream') {
emit('openErrorDetails', kind)
}
const updatedAtLabel = computed(() => {
if (!props.lastUpdated) return t('common.unknown')
return props.lastUpdated.toLocaleTimeString()
})
// --- Color coding for latency/TTFT ---
function getLatencyColor(ms: number | null | undefined): string {
if (ms == null) return 'text-gray-900 dark:text-white'
if (ms < 500) return 'text-green-600 dark:text-green-400'
if (ms < 1000) return 'text-yellow-600 dark:text-yellow-400'
if (ms < 2000) return 'text-orange-600 dark:text-orange-400'
return 'text-red-600 dark:text-red-400'
}
// --- Realtime / Overview labels ---
const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0))
const displayRealTimeQps = computed(() => {
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const v = useRealtime ? props.realTimeQps : ov.qps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
const displayRealTimeTps = computed(() => {
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const v = useRealtime ? props.realTimeTps : ov.tps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
// Sparkline history (keep last 60 data points)
const qpsHistory = ref<number[]>([])
const tpsHistory = ref<number[]>([])
const MAX_HISTORY_POINTS = 60
watch([displayRealTimeQps, displayRealTimeTps], ([newQps, newTps]) => {
// Add new data points
qpsHistory.value.push(newQps)
tpsHistory.value.push(newTps)
// Keep only last N points
if (qpsHistory.value.length > MAX_HISTORY_POINTS) {
qpsHistory.value.shift()
}
if (tpsHistory.value.length > MAX_HISTORY_POINTS) {
tpsHistory.value.shift()
}
})
const qpsPeakLabel = computed(() => {
const v = overview.value?.qps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const tpsPeakLabel = computed(() => {
const v = overview.value?.tps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const qpsAvgLabel = computed(() => {
const v = overview.value?.qps?.avg
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const tpsAvgLabel = computed(() => {
const v = overview.value?.tps?.avg
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const slaPercent = computed(() => {
const v = overview.value?.sla
if (typeof v !== 'number') return null
return v * 100
})
const errorRatePercent = computed(() => {
const v = overview.value?.error_rate
if (typeof v !== 'number') return null
return v * 100
})
const upstreamErrorRatePercent = computed(() => {
const v = overview.value?.upstream_error_rate
if (typeof v !== 'number') return null
return v * 100
})
const durationP99Ms = computed(() => overview.value?.duration?.p99_ms ?? null)
const durationP95Ms = computed(() => overview.value?.duration?.p95_ms ?? null)
const durationP90Ms = computed(() => overview.value?.duration?.p90_ms ?? null)
const durationP50Ms = computed(() => overview.value?.duration?.p50_ms ?? null)
const durationAvgMs = computed(() => overview.value?.duration?.avg_ms ?? null)
const durationMaxMs = computed(() => overview.value?.duration?.max_ms ?? null)
const ttftP99Ms = computed(() => overview.value?.ttft?.p99_ms ?? null)
const ttftP95Ms = computed(() => overview.value?.ttft?.p95_ms ?? null)
const ttftP90Ms = computed(() => overview.value?.ttft?.p90_ms ?? null)
const ttftP50Ms = computed(() => overview.value?.ttft?.p50_ms ?? null)
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
// --- Health Score & Diagnosis (primary) ---
const isSystemIdle = computed(() => {
const ov = overview.value
if (!ov) return true
const qps = props.wsStatus === 'connected' && props.wsHasData ? props.realTimeQps : ov.qps?.current
const errorRate = ov.error_rate ?? 0
return (qps ?? 0) === 0 && errorRate === 0
})
const healthScoreValue = computed<number | null>(() => {
const v = overview.value?.health_score
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const healthScoreColor = computed(() => {
if (isSystemIdle.value) return '#9ca3af' // gray-400
const score = healthScoreValue.value
if (score == null) return '#9ca3af'
if (score >= 90) return '#10b981' // green
if (score >= 60) return '#f59e0b' // yellow
return '#ef4444' // red
})
const healthScoreClass = computed(() => {
if (isSystemIdle.value) return 'text-gray-400'
const score = healthScoreValue.value
if (score == null) return 'text-gray-400'
if (score >= 90) return 'text-green-500'
if (score >= 60) return 'text-yellow-500'
return 'text-red-500'
})
const circleSize = 100
const strokeWidth = 8
const radius = (circleSize - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const dashOffset = computed(() => {
if (isSystemIdle.value) return 0
if (healthScoreValue.value == null) return 0
const score = Math.max(0, Math.min(100, healthScoreValue.value))
return circumference - (score / 100) * circumference
})
interface DiagnosisItem {
type: 'critical' | 'warning' | 'info'
message: string
impact: string
action?: string
}
const diagnosisReport = computed<DiagnosisItem[]>(() => {
const ov = overview.value
if (!ov) return []
const report: DiagnosisItem[] = []
if (isSystemIdle.value) {
report.push({
type: 'info',
message: t('admin.ops.diagnosis.idle'),
impact: t('admin.ops.diagnosis.idleImpact')
})
return report
}
// Resource diagnostics (highest priority)
const sm = ov.system_metrics
if (sm) {
if (sm.db_ok === false) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.dbDown'),
impact: t('admin.ops.diagnosis.dbDownImpact'),
action: t('admin.ops.diagnosis.dbDownAction')
})
}
if (sm.redis_ok === false) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.redisDown'),
impact: t('admin.ops.diagnosis.redisDownImpact'),
action: t('admin.ops.diagnosis.redisDownAction')
})
}
const cpuPct = sm.cpu_usage_percent ?? 0
if (cpuPct > 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.cpuCritical', { usage: cpuPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.cpuCriticalImpact'),
action: t('admin.ops.diagnosis.cpuCriticalAction')
})
} else if (cpuPct > 80) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.cpuHigh', { usage: cpuPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.cpuHighImpact'),
action: t('admin.ops.diagnosis.cpuHighAction')
})
}
const memPct = sm.memory_usage_percent ?? 0
if (memPct > 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.memoryCritical', { usage: memPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.memoryCriticalImpact'),
action: t('admin.ops.diagnosis.memoryCriticalAction')
})
} else if (memPct > 85) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.memoryHigh', { usage: memPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.memoryHighImpact'),
action: t('admin.ops.diagnosis.memoryHighAction')
})
}
}
// Latency diagnostics
const durationP99 = ov.duration?.p99_ms ?? 0
if (durationP99 > 2000) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.latencyCritical', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyCriticalImpact'),
action: t('admin.ops.diagnosis.latencyCriticalAction')
})
} else if (durationP99 > 1000) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.latencyHigh', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyHighImpact'),
action: t('admin.ops.diagnosis.latencyHighAction')
})
}
const ttftP99 = ov.ttft?.p99_ms ?? 0
if (ttftP99 > 500) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.ttftHigh', { ttft: ttftP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.ttftHighImpact'),
action: t('admin.ops.diagnosis.ttftHighAction')
})
}
// Error rate diagnostics (adjusted thresholds)
const upstreamRatePct = (ov.upstream_error_rate ?? 0) * 100
if (upstreamRatePct > 5) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.upstreamCritical', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamCriticalImpact'),
action: t('admin.ops.diagnosis.upstreamCriticalAction')
})
} else if (upstreamRatePct > 2) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.upstreamHigh', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamHighImpact'),
action: t('admin.ops.diagnosis.upstreamHighAction')
})
}
const errorPct = (ov.error_rate ?? 0) * 100
if (errorPct > 3) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.errorHigh', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorHighImpact'),
action: t('admin.ops.diagnosis.errorHighAction')
})
} else if (errorPct > 0.5) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.errorElevated', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorElevatedImpact'),
action: t('admin.ops.diagnosis.errorElevatedAction')
})
}
// SLA diagnostics
const slaPct = (ov.sla ?? 0) * 100
if (slaPct < 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.slaCritical', { sla: slaPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.slaCriticalImpact'),
action: t('admin.ops.diagnosis.slaCriticalAction')
})
} else if (slaPct < 98) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.slaLow', { sla: slaPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.slaLowImpact'),
action: t('admin.ops.diagnosis.slaLowAction')
})
}
// Health score diagnostics (lowest priority)
if (healthScoreValue.value != null) {
if (healthScoreValue.value < 60) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.healthCritical', { score: healthScoreValue.value }),
impact: t('admin.ops.diagnosis.healthCriticalImpact'),
action: t('admin.ops.diagnosis.healthCriticalAction')
})
} else if (healthScoreValue.value < 90) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.healthLow', { score: healthScoreValue.value }),
impact: t('admin.ops.diagnosis.healthLowImpact'),
action: t('admin.ops.diagnosis.healthLowAction')
})
}
}
if (report.length === 0) {
report.push({
type: 'info',
message: t('admin.ops.diagnosis.healthy'),
impact: t('admin.ops.diagnosis.healthyImpact')
})
}
return report
})
// --- System health (secondary) ---
function formatTimeShort(ts?: string | null): string {
if (!ts) return '-'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleTimeString()
}
const cpuPercentValue = computed<number | null>(() => {
const v = systemMetrics.value?.cpu_usage_percent
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const cpuPercentClass = computed(() => {
const v = cpuPercentValue.value
if (v == null) return 'text-gray-900 dark:text-white'
if (v >= 95) return 'text-rose-600 dark:text-rose-400'
if (v >= 80) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
})
const memPercentValue = computed<number | null>(() => {
const v = systemMetrics.value?.memory_usage_percent
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const memPercentClass = computed(() => {
const v = memPercentValue.value
if (v == null) return 'text-gray-900 dark:text-white'
if (v >= 95) return 'text-rose-600 dark:text-rose-400'
if (v >= 85) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
})
const dbConnActiveValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_active
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnIdleValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_idle
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnWaitingValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_waiting
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnOpenValue = computed<number | null>(() => {
if (dbConnActiveValue.value == null || dbConnIdleValue.value == null) return null
return dbConnActiveValue.value + dbConnIdleValue.value
})
const dbMaxOpenConnsValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_max_open_conns
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbUsagePercent = computed<number | null>(() => {
if (dbConnOpenValue.value == null || dbMaxOpenConnsValue.value == null || dbMaxOpenConnsValue.value <= 0) return null
return Math.min(100, Math.max(0, (dbConnOpenValue.value / dbMaxOpenConnsValue.value) * 100))
})
const dbMiddleLabel = computed(() => {
if (systemMetrics.value?.db_ok === false) return 'FAIL'
if (dbUsagePercent.value != null) return `${dbUsagePercent.value.toFixed(0)}%`
if (systemMetrics.value?.db_ok === true) return t('admin.ops.ok')
return t('admin.ops.noData')
})
const dbMiddleClass = computed(() => {
if (systemMetrics.value?.db_ok === false) return 'text-rose-600 dark:text-rose-400'
if (dbUsagePercent.value != null) {
if (dbUsagePercent.value >= 90) return 'text-rose-600 dark:text-rose-400'
if (dbUsagePercent.value >= 70) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
}
if (systemMetrics.value?.db_ok === true) return 'text-emerald-600 dark:text-emerald-400'
return 'text-gray-900 dark:text-white'
})
const redisConnTotalValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_conn_total
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisConnIdleValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_conn_idle
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisConnActiveValue = computed<number | null>(() => {
if (redisConnTotalValue.value == null || redisConnIdleValue.value == null) return null
return Math.max(redisConnTotalValue.value - redisConnIdleValue.value, 0)
})
const redisPoolSizeValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_pool_size
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisUsagePercent = computed<number | null>(() => {
if (redisConnTotalValue.value == null || redisPoolSizeValue.value == null || redisPoolSizeValue.value <= 0) return null
return Math.min(100, Math.max(0, (redisConnTotalValue.value / redisPoolSizeValue.value) * 100))
})
const redisMiddleLabel = computed(() => {
if (systemMetrics.value?.redis_ok === false) return 'FAIL'
if (redisUsagePercent.value != null) return `${redisUsagePercent.value.toFixed(0)}%`
if (systemMetrics.value?.redis_ok === true) return t('admin.ops.ok')
return t('admin.ops.noData')
})
const redisMiddleClass = computed(() => {
if (systemMetrics.value?.redis_ok === false) return 'text-rose-600 dark:text-rose-400'
if (redisUsagePercent.value != null) {
if (redisUsagePercent.value >= 90) return 'text-rose-600 dark:text-rose-400'
if (redisUsagePercent.value >= 70) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
}
if (systemMetrics.value?.redis_ok === true) return 'text-emerald-600 dark:text-emerald-400'
return 'text-gray-900 dark:text-white'
})
const goroutineCountValue = computed<number | null>(() => {
const v = systemMetrics.value?.goroutine_count
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const goroutinesWarnThreshold = 8_000
const goroutinesCriticalThreshold = 15_000
const goroutineStatus = computed<'ok' | 'warning' | 'critical' | 'unknown'>(() => {
const n = goroutineCountValue.value
if (n == null) return 'unknown'
if (n >= goroutinesCriticalThreshold) return 'critical'
if (n >= goroutinesWarnThreshold) return 'warning'
return 'ok'
})
const goroutineStatusLabel = computed(() => {
switch (goroutineStatus.value) {
case 'ok':
return t('admin.ops.ok')
case 'warning':
return t('common.warning')
case 'critical':
return t('common.critical')
default:
return t('admin.ops.noData')
}
})
const goroutineStatusClass = computed(() => {
switch (goroutineStatus.value) {
case 'ok':
return 'text-emerald-600 dark:text-emerald-400'
case 'warning':
return 'text-yellow-600 dark:text-yellow-400'
case 'critical':
return 'text-rose-600 dark:text-rose-400'
default:
return 'text-gray-900 dark:text-white'
}
})
const jobHeartbeats = computed(() => overview.value?.job_heartbeats ?? [])
const jobsStatus = computed<'ok' | 'warn' | 'unknown'>(() => {
const list = jobHeartbeats.value
if (!list.length) return 'unknown'
for (const hb of list) {
if (!hb) continue
if (hb.last_error_at && (!hb.last_success_at || hb.last_error_at > hb.last_success_at)) return 'warn'
}
return 'ok'
})
const jobsWarnCount = computed(() => {
let warn = 0
for (const hb of jobHeartbeats.value) {
if (!hb) continue
if (hb.last_error_at && (!hb.last_success_at || hb.last_error_at > hb.last_success_at)) warn++
}
return warn
})
const jobsStatusLabel = computed(() => {
switch (jobsStatus.value) {
case 'ok':
return t('admin.ops.ok')
case 'warn':
return t('common.warning')
default:
return t('admin.ops.noData')
}
})
const jobsStatusClass = computed(() => {
switch (jobsStatus.value) {
case 'ok':
return 'text-emerald-600 dark:text-emerald-400'
case 'warn':
return 'text-yellow-600 dark:text-yellow-400'
default:
return 'text-gray-900 dark:text-white'
}
})
const showJobsDetails = ref(false)
function openJobsDetails() {
showJobsDetails.value = true
}
</script>
<template>
<div class="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<!-- Top Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div>
<h1 class="flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
{{ t('admin.ops.title') }}
</h1>
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')">
<span class="relative flex h-2 w-2">
<span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span>
</span>
{{ props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready') }}
</span>
<span>·</span>
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
<template v-if="systemMetrics">
<span>·</span>
<span>
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
</span>
</template>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Select
:model-value="platform"
:options="platformOptions"
class="w-full sm:w-[140px]"
@update:model-value="handlePlatformChange"
/>
<Select
:model-value="groupId"
:options="groupOptions"
class="w-full sm:w-[160px]"
@update:model-value="handleGroupChange"
/>
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<Select
:model-value="timeRange"
:options="timeRangeOptions"
class="relative w-full sm:w-[150px]"
@update:model-value="handleTimeRangeChange"
/>
<Select
v-if="false"
:model-value="queryMode"
:options="queryModeOptions"
class="relative w-full sm:w-[170px]"
@update:model-value="handleQueryModeChange"
/>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="emit('refresh')"
>
<svg class="h-4 w-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
:title="t('admin.ops.alertRules.title')"
@click="emit('openAlertRules')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
</button>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:title="t('admin.ops.settings.title')"
@click="emit('openSettings')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="hidden sm:inline">{{ t('common.settings') }}</span>
</button>
</div>
</div>
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<!-- Left: Health + Realtime -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900 lg:col-span-5">
<div class="grid grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
<!-- 1) Health Score -->
<div
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700"
>
<!-- Diagnosis Popover (hover) -->
<div
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 w-72 -translate-x-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:left-full md:top-0 md:ml-2 md:mt-0 md:translate-x-0"
>
<div class="rounded-xl bg-white p-4 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
<h4 class="mb-3 border-b border-gray-100 pb-2 text-sm font-bold text-gray-900 dark:border-gray-700 dark:text-white">
🧠 {{ t('admin.ops.diagnosis.title') }}
</h4>
<div class="space-y-3">
<div v-for="(item, idx) in diagnosisReport" :key="idx" class="flex gap-3">
<div class="mt-0.5 shrink-0">
<svg v-if="item.type === 'critical'" class="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<svg v-else-if="item.type === 'warning'" class="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 100 2 1 1 0 000-2zm-1 3a1 1 0 012 0v4a1 1 0 11-2 0v-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-semibold text-gray-900 dark:text-white">{{ item.message }}</div>
<div class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{{ item.impact }}</div>
<div v-if="item.action" class="mt-1 text-[11px] text-blue-600 dark:text-blue-400">
💡 {{ item.action }}
</div>
</div>
</div>
</div>
<div class="mt-3 border-t border-gray-100 pt-2 text-[10px] text-gray-400 dark:border-gray-700">
{{ t('admin.ops.diagnosis.footer') }}
</div>
</div>
</div>
<div class="relative flex items-center justify-center">
<svg :width="circleSize" :height="circleSize" class="-rotate-90 transform">
<circle
:cx="circleSize / 2"
:cy="circleSize / 2"
:r="radius"
:stroke-width="strokeWidth"
fill="transparent"
class="text-gray-200 dark:text-dark-700"
stroke="currentColor"
/>
<circle
:cx="circleSize / 2"
:cy="circleSize / 2"
:r="radius"
:stroke-width="strokeWidth"
fill="transparent"
:stroke="healthScoreColor"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="dashOffset"
class="transition-all duration-1000 ease-out"
/>
</svg>
<div class="absolute flex flex-col items-center">
<span class="text-3xl font-black" :class="healthScoreClass">
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
</span>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.health') }}</span>
</div>
</div>
<div class="mt-4 text-center">
<div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500">
{{ t('admin.ops.healthCondition') }}
<HelpTooltip :content="t('admin.ops.healthHelp')" />
</div>
<div class="mt-1 text-xs font-bold" :class="healthScoreClass">
{{
isSystemIdle
? t('admin.ops.idleStatus')
: typeof overview.health_score === 'number' && overview.health_score >= 90
? t('admin.ops.healthyStatus')
: t('admin.ops.riskyStatus')
}}
</div>
</div>
</div>
<!-- 2) Realtime Traffic -->
<div class="flex flex-col justify-center py-2">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="relative flex h-3 w-3 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
</div>
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3>
<HelpTooltip :content="t('admin.ops.tooltips.qps')" />
</div>
<!-- Time Window Selector -->
<div class="flex flex-wrap gap-1">
<button
v-for="window in (['1min', '5min', '30min', '1h'] as RealtimeWindow[])"
:key="window"
type="button"
class="rounded px-1.5 py-0.5 text-[9px] font-bold transition-colors sm:px-2 sm:text-[10px]"
:class="realtimeWindow === window
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-600 hover:bg-gray-300 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'"
@click="realtimeWindow = window"
>
{{ window }}
</button>
</div>
</div>
<div class="space-y-3">
<!-- Row 1: Current -->
<div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.current') }}</div>
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeQps.toFixed(1) }}</span>
<span class="text-xs font-bold text-gray-500">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeTps.toFixed(1) }}</span>
<span class="text-xs font-bold text-gray-500">TPS</span>
</div>
</div>
</div>
<!-- Row 2: Peak + Average -->
<div class="grid grid-cols-2 gap-3">
<!-- Peak -->
<div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.peak') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ qpsPeakLabel }}</span>
<span class="text-xs">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ tpsPeakLabel }}</span>
<span class="text-xs">TPS</span>
</div>
</div>
</div>
<!-- Average -->
<div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.average') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ qpsAvgLabel }}</span>
<span class="text-xs">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ tpsAvgLabel }}</span>
<span class="text-xs">TPS</span>
</div>
</div>
</div>
</div>
<!-- Animated Pulse Line (Heart Beat Animation) -->
<div class="h-8 w-full overflow-hidden opacity-50">
<svg class="h-full w-full" viewBox="0 0 280 32" preserveAspectRatio="none">
<path
d="M0 16 Q 20 16, 40 16 T 80 16 T 120 10 T 160 22 T 200 16 T 240 16 T 280 16"
fill="none"
stroke="#3b82f6"
stroke-width="2"
vector-effect="non-scaling-stroke"
>
<animate
attributeName="d"
dur="2s"
repeatCount="indefinite"
values="M0 16 Q 20 16, 40 16 T 80 16 T 120 10 T 160 22 T 200 16 T 240 16 T 280 16;
M0 16 Q 20 16, 40 16 T 80 16 T 120 16 T 160 16 T 200 10 T 240 22 T 280 16;
M0 16 Q 20 16, 40 16 T 80 16 T 120 16 T 160 16 T 200 16 T 240 16 T 280 16"
keyTimes="0;0.5;1"
/>
</path>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requests') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.totalRequests')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.requests') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ totalRequestsLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.tokens') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ totalTokensLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgQps') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ qpsAvgLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgTps') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ tpsAvgLabel }}</span>
</div>
</div>
</div>
<!-- Card 2: SLA -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
<HelpTooltip :content="t('admin.ops.tooltips.sla')" />
<span class="h-1.5 w-1.5 rounded-full" :class="(slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black text-gray-900 dark:text-white">
{{ slaPercent == null ? '-' : `${slaPercent.toFixed(3)}%` }}
</div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div class="h-full bg-green-500 transition-all" :style="{ width: `${Math.max((slaPercent ?? 0) - 90, 0) * 10}%` }"></div>
</div>
<div class="mt-3 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.exceptions') }}:</span>
<span class="font-bold text-red-600 dark:text-red-400">{{ formatNumber((overview.request_count_sla ?? 0) - (overview.success_count ?? 0)) }}</span>
</div>
</div>
</div>
<!-- Card 3: Latency (Duration) -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.latency')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="getLatencyColor(durationP99Ms)">
{{ durationP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span>
<span class="font-bold" :class="getLatencyColor(durationP95Ms)">{{ durationP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(durationP90Ms)">{{ durationP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(durationP50Ms)">{{ durationP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(durationMaxMs)">{{ durationMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 4: TTFT -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
<HelpTooltip :content="t('admin.ops.tooltips.ttft')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: 'TTFT' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="getLatencyColor(ttftP99Ms)">
{{ ttftP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span>
<span class="font-bold" :class="getLatencyColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 5: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.errors')" />
</div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black" :class="(errorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
{{ errorRatePercent == null ? '-' : `${errorRatePercent.toFixed(2)}%` }}
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.errorCount') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.error_count_sla ?? 0) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.businessLimited') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.business_limited_count ?? 0) }}</span>
</div>
</div>
</div>
<!-- Card 6: Upstream Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.upstreamErrors')" />
</div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black" :class="(upstreamErrorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
{{ upstreamErrorRatePercent == null ? '-' : `${upstreamErrorRatePercent.toFixed(2)}%` }}
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.errorCountExcl429529') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.upstream_error_count_excl_429_529 ?? 0) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">429/529:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber((overview.upstream_429_count ?? 0) + (overview.upstream_529_count ?? 0)) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Integrated: System health (cards) -->
<div v-if="overview" class="mt-2 border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<!-- CPU -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
<HelpTooltip :content="t('admin.ops.tooltips.cpu')" />
</div>
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.warning') }} 80% · {{ t('common.critical') }} 95%
</div>
</div>
<!-- MEM -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div>
<HelpTooltip :content="t('admin.ops.tooltips.memory')" />
</div>
<div class="mt-1 text-lg font-black" :class="memPercentClass">
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{
systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null
? '-'
: `${formatNumber(systemMetrics.memory_used_mb)} / ${formatNumber(systemMetrics.memory_total_mb)} MB`
}}
</div>
</div>
<!-- DB -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div>
<HelpTooltip :content="t('admin.ops.tooltips.db')" />
</div>
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
{{ dbMiddleLabel }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }}
· {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }}
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
<span v-if="dbConnWaitingValue != null"> · {{ t('admin.ops.waiting') }} {{ dbConnWaitingValue }} </span>
</div>
</div>
<!-- Redis -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
<HelpTooltip :content="t('admin.ops.tooltips.redis')" />
</div>
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
{{ redisMiddleLabel }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }}
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
</div>
</div>
<!-- Goroutines -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div>
<HelpTooltip :content="t('admin.ops.tooltips.goroutines')" />
</div>
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
{{ goroutineStatusLabel }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
<span v-if="systemMetrics?.concurrency_queue_depth != null">
· {{ t('admin.ops.queue') }} <span class="font-mono">{{ systemMetrics.concurrency_queue_depth }}</span>
</span>
</div>
</div>
<!-- Jobs -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div>
<HelpTooltip :content="t('admin.ops.tooltips.jobs')" />
</div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-1 text-lg font-black" :class="jobsStatusClass">
{{ jobsStatusLabel }}
</div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
</div>
</div>
</div>
</div>
<BaseDialog :show="showJobsDetails" :title="t('admin.ops.jobs')" width="wide" @close="showJobsDetails = false">
<div v-if="!jobHeartbeats.length" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.noData') }}
</div>
<div v-else class="space-y-3">
<div
v-for="hb in jobHeartbeats"
:key="hb.job_name"
class="rounded-xl border border-gray-100 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between gap-3">
<div class="truncate text-sm font-semibold text-gray-900 dark:text-white">{{ hb.job_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatTimeShort(hb.updated_at) }}</div>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
<div>
{{ t('admin.ops.lastSuccess') }} <span class="font-mono">{{ formatTimeShort(hb.last_success_at) }}</span>
</div>
<div>
{{ t('admin.ops.lastError') }} <span class="font-mono">{{ formatTimeShort(hb.last_error_at) }}</span>
</div>
</div>
<div
v-if="hb.last_error"
class="mt-3 rounded-lg bg-rose-50 p-2 text-xs text-rose-700 dark:bg-rose-900/20 dark:text-rose-300"
>
{{ hb.last_error }}
</div>
</div>
</div>
</BaseDialog>
</div>
</template>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<div class="h-5 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-72 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="flex items-center gap-3">
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div v-for="i in 4" :key="i" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-3 h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
<!-- Cards -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div
v-for="i in 3"
:key="i"
class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
>
<div class="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-4 space-y-3">
<div class="h-3 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-1/2 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-3/5 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { opsAPI } from '@/api/admin/ops'
import type { EmailNotificationConfig, AlertSeverity } from '../types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const config = ref<EmailNotificationConfig | null>(null)
const showEditor = ref(false)
const saving = ref(false)
const draft = ref<EmailNotificationConfig | null>(null)
const alertRecipientInput = ref('')
const reportRecipientInput = ref('')
const alertRecipientError = ref('')
const reportRecipientError = ref('')
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
{ value: '', label: t('admin.ops.email.minSeverityAll') },
{ value: 'critical', label: t('common.critical') },
{ value: 'warning', label: t('common.warning') },
{ value: 'info', label: t('common.info') }
]
async function loadConfig() {
loading.value = true
try {
const data = await opsAPI.getEmailNotificationConfig()
config.value = data
} catch (err: any) {
console.error('[OpsEmailNotificationCard] Failed to load config', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.loadFailed'))
} finally {
loading.value = false
}
}
async function saveConfig() {
if (!draft.value) return
if (!editorValidation.value.valid) {
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.email.validation.invalid'))
return
}
saving.value = true
try {
config.value = await opsAPI.updateEmailNotificationConfig(draft.value)
showEditor.value = false
appStore.showSuccess(t('admin.ops.email.saveSuccess'))
} catch (err: any) {
console.error('[OpsEmailNotificationCard] Failed to save config', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.saveFailed'))
} finally {
saving.value = false
}
}
function openEditor() {
if (!config.value) return
draft.value = JSON.parse(JSON.stringify(config.value))
alertRecipientInput.value = ''
reportRecipientInput.value = ''
alertRecipientError.value = ''
reportRecipientError.value = ''
showEditor.value = true
}
function isValidEmailAddress(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function isNonNegativeNumber(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function validateCronField(enabled: boolean, cron: string): string | null {
if (!enabled) return null
if (!cron || !cron.trim()) return t('admin.ops.email.validation.cronRequired')
if (cron.trim().split(/\s+/).length < 5) return t('admin.ops.email.validation.cronFormat')
return null
}
const editorValidation = computed(() => {
const errors: string[] = []
if (!draft.value) return { valid: true, errors }
if (draft.value.alert.enabled && draft.value.alert.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
}
if (draft.value.report.enabled && draft.value.report.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
}
const invalidAlertRecipients = draft.value.alert.recipients.filter((e) => !isValidEmailAddress(e))
if (invalidAlertRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
const invalidReportRecipients = draft.value.report.recipients.filter((e) => !isValidEmailAddress(e))
if (invalidReportRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
if (!isNonNegativeNumber(draft.value.alert.rate_limit_per_hour)) {
errors.push(t('admin.ops.email.validation.rateLimitRange'))
}
if (
!isNonNegativeNumber(draft.value.alert.batching_window_seconds) ||
draft.value.alert.batching_window_seconds > 86400
) {
errors.push(t('admin.ops.email.validation.batchWindowRange'))
}
const dailyErr = validateCronField(
draft.value.report.daily_summary_enabled,
draft.value.report.daily_summary_schedule
)
if (dailyErr) errors.push(dailyErr)
const weeklyErr = validateCronField(
draft.value.report.weekly_summary_enabled,
draft.value.report.weekly_summary_schedule
)
if (weeklyErr) errors.push(weeklyErr)
const digestErr = validateCronField(
draft.value.report.error_digest_enabled,
draft.value.report.error_digest_schedule
)
if (digestErr) errors.push(digestErr)
const accErr = validateCronField(
draft.value.report.account_health_enabled,
draft.value.report.account_health_schedule
)
if (accErr) errors.push(accErr)
if (!isNonNegativeNumber(draft.value.report.error_digest_min_count)) {
errors.push(t('admin.ops.email.validation.digestMinCountRange'))
}
const thr = draft.value.report.account_health_error_rate_threshold
if (!(typeof thr === 'number' && Number.isFinite(thr) && thr >= 0 && thr <= 100)) {
errors.push(t('admin.ops.email.validation.accountHealthThresholdRange'))
}
return { valid: errors.length === 0, errors }
})
function addRecipient(target: 'alert' | 'report') {
if (!draft.value) return
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
if (!raw) return
if (!isValidEmailAddress(raw)) {
const msg = t('common.invalidEmail')
if (target === 'alert') alertRecipientError.value = msg
else reportRecipientError.value = msg
return
}
const normalized = raw.toLowerCase()
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
if (!list.includes(normalized)) {
list.push(normalized)
}
if (target === 'alert') alertRecipientInput.value = ''
else reportRecipientInput.value = ''
if (target === 'alert') alertRecipientError.value = ''
else reportRecipientError.value = ''
}
function removeRecipient(target: 'alert' | 'report', email: string) {
if (!draft.value) return
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
const idx = list.indexOf(email)
if (idx >= 0) list.splice(idx, 1)
}
onMounted(() => {
loadConfig()
})
</script>
<template>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.email.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.description') }}</p>
</div>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
@click="loadConfig"
>
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.refresh') }}
</button>
<button class="btn btn-sm btn-secondary" :disabled="!config" @click="openEditor">{{ t('common.edit') }}</button>
</div>
</div>
<div v-if="!config" class="text-sm text-gray-500 dark:text-gray-400">
<span v-if="loading">{{ t('admin.ops.email.loading') }}</span>
<span v-else>{{ t('admin.ops.email.noData') }}</span>
</div>
<div v-else class="space-y-6">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('common.enabled') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">
{{ config.alert.enabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.email.recipients') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.recipients.length }}</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.email.minSeverity') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{
config.alert.min_severity || t('admin.ops.email.minSeverityAll')
}}</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.email.rateLimitPerHour') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.rate_limit_per_hour }}</span>
</div>
</div>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('common.enabled') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">
{{ config.report.enabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.email.recipients') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.report.recipients.length }}</span>
</div>
</div>
</div>
</div>
</div>
<BaseDialog :show="showEditor" :title="t('admin.ops.email.title')" width="extra-wide" @close="showEditor = false">
<div v-if="draft" class="space-y-6">
<div
v-if="!editorValidation.valid"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
>
<div class="font-bold">{{ t('admin.ops.email.validation.title') }}</div>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li v-for="msg in editorValidation.errors" :key="msg">{{ msg }}</li>
</ul>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.alert.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ draft.alert.enabled ? t('common.enabled') : t('common.disabled') }}</span>
</label>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.minSeverity') }}</div>
<Select v-model="draft.alert.min_severity" :options="severityOptions" />
</div>
<div class="md:col-span-2">
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
<div class="flex gap-2">
<input
v-model="alertRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.email.recipients')"
@keydown.enter.prevent="addRecipient('alert')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
{{ t('common.add') }}
</button>
</div>
<p v-if="alertRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ alertRecipientError }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in draft.alert.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button
type="button"
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
@click="removeRecipient('alert', email)"
>
×
</button>
</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.recipientsHint') }}</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.rateLimitPerHour') }}</div>
<input v-model.number="draft.alert.rate_limit_per_hour" type="number" min="0" max="100000" class="input" />
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.batchWindowSeconds') }}</div>
<input v-model.number="draft.alert.batching_window_seconds" type="number" min="0" max="86400" class="input" />
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.includeResolved') }}</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.alert.include_resolved_alerts" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ draft.alert.include_resolved_alerts ? t('common.enabled') : t('common.disabled') }}</span>
</label>
</div>
</div>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.report.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ draft.report.enabled ? t('common.enabled') : t('common.disabled') }}</span>
</label>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
<div class="flex gap-2">
<input
v-model="reportRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.email.recipients')"
@keydown.enter.prevent="addRecipient('report')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
{{ t('common.add') }}
</button>
</div>
<p v-if="reportRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ reportRecipientError }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in draft.report.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button
type="button"
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
@click="removeRecipient('report', email)"
>
×
</button>
</span>
</div>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.dailySummary') }}</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.report.daily_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
</label>
<input v-model="draft.report.daily_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.weeklySummary') }}</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.report.weekly_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
</label>
<input v-model="draft.report.weekly_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigest') }}</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.report.error_digest_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
</label>
<input v-model="draft.report.error_digest_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigestMinCount') }}</div>
<input v-model.number="draft.report.error_digest_min_count" type="number" min="0" max="1000000" class="input" />
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealth') }}</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draft.report.account_health_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
</label>
<input v-model="draft.report.account_health_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealthThreshold') }}</div>
<input v-model.number="draft.report.account_health_error_rate_threshold" type="number" min="0" max="100" step="0.1" class="input" />
</div>
</div>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.reportHint') }}</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="showEditor = false">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="saving || !editorValidation.valid" @click="saveConfig">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<template>
<BaseDialog :show="show" :title="title" width="full" :close-on-click-outside="true" @close="close">
<div v-if="loading" class="flex items-center justify-center py-16">
<div class="flex flex-col items-center gap-3">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.loading') }}</div>
</div>
</div>
<div v-else-if="!detail" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
{{ emptyText }}
</div>
<div v-else class="space-y-6 p-6">
<!-- Top Summary -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestId') }}</div>
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
{{ detail.request_id || detail.client_request_id || '' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.time') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ formatDateTime(detail.created_at) }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.phase') }}</div>
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">
{{ detail.phase || '' }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ detail.type || '' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.status') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
{{ detail.status_code }}
</span>
<span
v-if="detail.severity"
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', severityClass]"
>
{{ detail.severity }}
</span>
</div>
</div>
</div>
<!-- Message -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.message') }}</h3>
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words">
{{ detail.message || '' }}
</div>
</div>
<!-- Basic Info -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.basicInfo') }}</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.platform || '' }}</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.model || '' }}</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.latency') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.latency_ms != null ? `${detail.latency_ms}ms` : '' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.ttft') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.time_to_first_token_ms != null ? `${detail.time_to_first_token_ms}ms` : '' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.businessLimited') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.is_business_limited ? 'true' : 'false' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.requestPath') }}</div>
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200 break-all">
{{ detail.request_path || '' }}
</div>
</div>
</div>
</div>
<!-- Timings (best-effort fields) -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.timings') }}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.auth') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.auth_latency_ms != null ? `${detail.auth_latency_ms}ms` : '' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.routing') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.routing_latency_ms != null ? `${detail.routing_latency_ms}ms` : '' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstream') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.upstream_latency_ms != null ? `${detail.upstream_latency_ms}ms` : '' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.response') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.response_latency_ms != null ? `${detail.response_latency_ms}ms` : '' }}
</div>
</div>
</div>
</div>
<!-- Retry -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start">
<div class="space-y-1">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetail.retryNote1') }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="retrying" @click="openRetryConfirm('client')">
{{ t('admin.ops.errorDetail.retryClient') }}
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="retrying || !pinnedAccountId"
@click="openRetryConfirm('upstream')"
:title="pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')"
>
{{ t('admin.ops.errorDetail.retryUpstream') }}
</button>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="md:col-span-1">
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label>
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" :placeholder="t('admin.ops.errorDetail.pinnedAccountIdHint')" />
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetail.retryNote2') }}
</div>
</div>
<div class="md:col-span-2">
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.retryNotes') }}</div>
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs text-gray-600 dark:text-gray-300">
<li>{{ t('admin.ops.errorDetail.retryNote3') }}</li>
<li>{{ t('admin.ops.errorDetail.retryNote4') }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Upstream errors -->
<div
v-if="detail.upstream_status_code || detail.upstream_error_message || detail.upstream_error_detail || detail.upstream_errors"
class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"
>
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">
{{ t('admin.ops.errorDetails.upstreamErrors') }}
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<div class="text-xs font-bold uppercase text-gray-400">status</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.upstream_status_code != null ? detail.upstream_status_code : '' }}
</div>
</div>
<div class="sm:col-span-2">
<div class="text-xs font-bold uppercase text-gray-400">message</div>
<div class="mt-1 break-words text-sm font-medium text-gray-900 dark:text-white">
{{ detail.upstream_error_message || '' }}
</div>
</div>
</div>
<div v-if="detail.upstream_error_detail" class="mt-4">
<div class="text-xs font-bold uppercase text-gray-400">detail</div>
<pre
class="mt-2 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.upstream_error_detail) }}</code></pre>
</div>
<div v-if="detail.upstream_errors" class="mt-5">
<div class="mb-2 text-xs font-bold uppercase text-gray-400">upstream_errors</div>
<div v-if="upstreamErrors.length" class="space-y-3">
<div
v-for="(ev, idx) in upstreamErrors"
:key="idx"
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs font-black text-gray-800 dark:text-gray-100">
#{{ idx + 1 }} <span v-if="ev.kind" class="font-mono">{{ ev.kind }}</span>
</div>
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
{{ ev.at_unix_ms ? formatDateTime(new Date(ev.at_unix_ms)) : '' }}
</div>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-3">
<div><span class="text-gray-400">account_id:</span> <span class="font-mono">{{ ev.account_id ?? '—' }}</span></div>
<div><span class="text-gray-400">status:</span> <span class="font-mono">{{ ev.upstream_status_code ?? '—' }}</span></div>
<div class="break-all">
<span class="text-gray-400">request_id:</span> <span class="font-mono">{{ ev.upstream_request_id || '—' }}</span>
</div>
</div>
<div v-if="ev.message" class="mt-2 break-words text-sm font-medium text-gray-900 dark:text-white">
{{ ev.message }}
</div>
<pre
v-if="ev.detail"
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
><code>{{ prettyJSON(ev.detail) }}</code></pre>
</div>
</div>
<pre
v-else
class="max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.upstream_errors) }}</code></pre>
</div>
</div>
<!-- Request body -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<div class="flex items-center justify-between">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.requestBody') }}</h3>
<div
v-if="detail.request_body_truncated"
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.ops.errorDetail.trimmed') }}
</div>
</div>
<pre
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.request_body) }}</code></pre>
</div>
<!-- Error body -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.errorBody') }}</h3>
<pre
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.error_body) }}</code></pre>
</div>
</div>
</BaseDialog>
<ConfirmDialog
:show="showRetryConfirm"
:title="t('admin.ops.errorDetail.confirmRetry')"
:message="retryConfirmMessage"
@confirm="runConfirmedRetry"
@cancel="cancelRetry"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail, type OpsRetryMode } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format'
import { getSeverityClass } from '../utils/opsFormatters'
interface Props {
show: boolean
errorId: number | null
}
interface Emits {
(e: 'update:show', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const detail = ref<OpsErrorDetail | null>(null)
const retrying = ref(false)
const showRetryConfirm = ref(false)
const pendingRetryMode = ref<OpsRetryMode>('client')
const pinnedAccountIdInput = ref('')
const pinnedAccountId = computed<number | null>(() => {
const raw = String(pinnedAccountIdInput.value || '').trim()
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
})
const title = computed(() => {
if (!props.errorId) return 'Error Detail'
return `Error #${props.errorId}`
})
const emptyText = computed(() => 'No error selected.')
type UpstreamErrorEvent = {
at_unix_ms?: number
platform?: string
account_id?: number
upstream_status_code?: number
upstream_request_id?: string
kind?: string
message?: string
detail?: string
}
const upstreamErrors = computed<UpstreamErrorEvent[]>(() => {
const raw = detail.value?.upstream_errors
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as UpstreamErrorEvent[]) : []
} catch {
return []
}
})
function close() {
emit('update:show', false)
}
function prettyJSON(raw?: string): string {
if (!raw) return t('admin.ops.errorDetail.na')
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
async function fetchDetail(id: number) {
loading.value = true
try {
const d = await opsAPI.getErrorLogDetail(id)
detail.value = d
// Default pinned account from error log if present.
if (d.account_id && d.account_id > 0) {
pinnedAccountIdInput.value = String(d.account_id)
} else {
pinnedAccountIdInput.value = ''
}
} catch (err: any) {
detail.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail'))
} finally {
loading.value = false
}
}
watch(
() => [props.show, props.errorId] as const,
([show, id]) => {
if (!show) {
detail.value = null
return
}
if (typeof id === 'number' && id > 0) {
fetchDetail(id)
}
},
{ immediate: true }
)
function openRetryConfirm(mode: OpsRetryMode) {
pendingRetryMode.value = mode
showRetryConfirm.value = true
}
const retryConfirmMessage = computed(() => {
const mode = pendingRetryMode.value
if (mode === 'upstream') {
return t('admin.ops.errorDetail.confirmRetryMessage')
}
return t('admin.ops.errorDetail.confirmRetryHint')
})
const severityClass = computed(() => {
if (!detail.value?.severity) return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
return getSeverityClass(detail.value.severity)
})
const statusClass = computed(() => {
const code = detail.value?.status_code ?? 0
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
})
async function runConfirmedRetry() {
if (!props.errorId) return
const mode = pendingRetryMode.value
showRetryConfirm.value = false
retrying.value = true
try {
const req =
mode === 'upstream'
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined }
: { mode }
const res = await opsAPI.retryErrorRequest(props.errorId, req)
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
appStore.showSuccess(summary)
} catch (err: any) {
appStore.showError(err?.message || t('admin.ops.retryFailed'))
} finally {
retrying.value = false
}
}
function cancelRetry() {
showRetryConfirm.value = false
}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import OpsErrorLogTable from './OpsErrorLogTable.vue'
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
interface Props {
show: boolean
timeRange: string
platform?: string
groupId?: number | null
errorType: 'request' | 'upstream'
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'openErrorDetail', errorId: number): void
}>()
const { t } = useI18n()
const loading = ref(false)
const rows = ref<OpsErrorLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const q = ref('')
const statusCode = ref<number | null>(null)
const phase = ref<string>('')
const accountIdInput = ref<string>('')
const accountId = computed<number | null>(() => {
const raw = String(accountIdInput.value || '').trim()
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
})
const modalTitle = computed(() => {
return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors')
})
const statusCodeSelectOptions = computed(() => {
const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529]
return [
{ value: null, label: t('common.all') },
...codes.map((c) => ({ value: c, label: String(c) }))
]
})
const phaseSelectOptions = computed(() => {
const options = [
{ value: '', label: t('common.all') },
{ value: 'upstream', label: 'upstream' },
{ value: 'network', label: 'network' },
{ value: 'routing', label: 'routing' },
{ value: 'auth', label: 'auth' },
{ value: 'billing', label: 'billing' },
{ value: 'concurrency', label: 'concurrency' },
{ value: 'internal', label: 'internal' }
]
return options
})
function close() {
emit('update:show', false)
}
async function fetchErrorLogs() {
if (!props.show) return
loading.value = true
try {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange
}
const platform = String(props.platform || '').trim()
if (platform) params.platform = platform
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
if (q.value.trim()) params.q = q.value.trim()
if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value)
if (typeof accountId.value === 'number') params.account_id = accountId.value
const phaseVal = String(phase.value || '').trim()
if (phaseVal) params.phase = phaseVal
const res = await opsAPI.listErrorLogs(params)
rows.value = res.items || []
total.value = res.total || 0
} catch (err) {
console.error('[OpsErrorDetailsModal] Failed to fetch error logs', err)
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
function resetFilters() {
q.value = ''
statusCode.value = null
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
accountIdInput.value = ''
page.value = 1
fetchErrorLogs()
}
watch(
() => props.show,
(open) => {
if (!open) return
page.value = 1
pageSize.value = 20
resetFilters()
}
)
watch(
() => [props.timeRange, props.platform, props.groupId] as const,
() => {
if (!props.show) return
page.value = 1
fetchErrorLogs()
}
)
watch(
() => [page.value, pageSize.value] as const,
() => {
if (!props.show) return
fetchErrorLogs()
}
)
let searchTimeout: number | null = null
watch(
() => q.value,
() => {
if (!props.show) return
if (searchTimeout) window.clearTimeout(searchTimeout)
searchTimeout = window.setTimeout(() => {
page.value = 1
fetchErrorLogs()
}, 350)
}
)
watch(
() => [statusCode.value, phase.value] as const,
() => {
if (!props.show) return
page.value = 1
fetchErrorLogs()
}
)
watch(
() => accountId.value,
() => {
if (!props.show) return
page.value = 1
fetchErrorLogs()
}
)
</script>
<template>
<BaseDialog :show="show" :title="modalTitle" width="full" @close="close">
<!-- Filters -->
<div class="border-b border-gray-200 pb-4 mb-4 dark:border-dark-700">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div class="lg:col-span-5">
<div class="relative group">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<svg
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
v-model="q"
type="text"
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
/>
</div>
</div>
<div class="lg:col-span-2">
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" />
</div>
<div class="lg:col-span-2">
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" />
</div>
<div class="lg:col-span-2">
<input
v-model="accountIdInput"
type="text"
inputmode="numeric"
class="input w-full text-sm"
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
/>
</div>
<div class="lg:col-span-1 flex items-center justify-end">
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
{{ t('common.reset') }}
</button>
</div>
</div>
</div>
<!-- Body -->
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('admin.ops.errorDetails.total') }} {{ total }}
</div>
<OpsErrorLogTable
:rows="rows"
:total="total"
:loading="loading"
:page="page"
:page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)"
@update:page="page = $event"
@update:pageSize="pageSize = $event"
/>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, ArcElement, Legend, Tooltip } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import type { OpsErrorDistributionResponse } from '@/api/admin/ops'
import type { ChartState } from '../types'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import EmptyState from '@/components/common/EmptyState.vue'
ChartJS.register(ArcElement, Tooltip, Legend)
interface Props {
data: OpsErrorDistributionResponse | null
loading: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'openDetails'): void
}>()
const { t } = useI18n()
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
const colors = computed(() => ({
blue: '#3b82f6',
red: '#ef4444',
orange: '#f59e0b',
gray: '#9ca3af',
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const hasData = computed(() => (props.data?.total ?? 0) > 0)
const state = computed<ChartState>(() => {
if (hasData.value) return 'ready'
if (props.loading) return 'loading'
return 'empty'
})
interface ErrorCategory {
label: string
count: number
color: string
}
const categories = computed<ErrorCategory[]>(() => {
if (!props.data) return []
let upstream = 0 // 502, 503, 504
let client = 0 // 4xx
let system = 0 // 500
let other = 0
for (const item of props.data.items || []) {
const code = Number(item.status_code || 0)
const count = Number(item.total || 0)
if (!Number.isFinite(code) || !Number.isFinite(count)) continue
if ([502, 503, 504].includes(code)) upstream += count
else if (code >= 400 && code < 500) client += count
else if (code === 500) system += count
else other += count
}
const out: ErrorCategory[] = []
if (upstream > 0) out.push({ label: t('admin.ops.upstream'), count: upstream, color: colors.value.orange })
if (client > 0) out.push({ label: t('admin.ops.client'), count: client, color: colors.value.blue })
if (system > 0) out.push({ label: t('admin.ops.system'), count: system, color: colors.value.red })
if (other > 0) out.push({ label: t('admin.ops.other'), count: other, color: colors.value.gray })
return out
})
const topReason = computed(() => {
if (categories.value.length === 0) return null
return categories.value.reduce((prev, cur) => (cur.count > prev.count ? cur : prev))
})
const chartData = computed(() => {
if (!hasData.value || categories.value.length === 0) return null
return {
labels: categories.value.map((c) => c.label),
datasets: [
{
data: categories.value.map((c) => c.count),
backgroundColor: categories.value.map((c) => c.color),
borderWidth: 0
}
]
}
})
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563'
}
}
}))
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ t('admin.ops.errorDistribution') }}
<HelpTooltip :content="t('admin.ops.tooltips.errorDistribution')" />
</h3>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="state !== 'ready'"
:title="t('admin.ops.errorTrend')"
@click="emit('openDetails')"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="relative min-h-0 flex-1">
<div v-if="state === 'ready' && chartData" class="flex h-full flex-col">
<div class="flex-1">
<Doughnut :data="chartData" :options="{ ...options, cutout: '65%' }" />
</div>
<div class="mt-4 flex flex-col items-center gap-2">
<div v-if="topReason" class="text-xs font-bold text-gray-900 dark:text-white">
{{ t('admin.ops.top') }}: <span :style="{ color: topReason.color }">{{ topReason.label }}</span>
</div>
<div class="flex flex-wrap justify-center gap-3">
<div v-for="item in categories" :key="item.label" class="flex items-center gap-1.5 text-xs">
<span class="h-2 w-2 rounded-full" :style="{ backgroundColor: item.color }"></span>
<span class="text-gray-500 dark:text-gray-400">{{ item.count }}</span>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center">
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
</div>
</div>
</div>
</template>
<template>
<div>
<div v-if="loading" class="flex items-center justify-center py-10">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
<tr>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.timeId') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.context') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.status') }}
</th>
<th
scope="col"
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.message') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.latency') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.action') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }}
</td>
</tr>
<tr
v-for="log in rows"
:key="log.id"
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900"
tabindex="0"
role="button"
@click="emit('openErrorDetail', log.id)"
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
@keydown.space.prevent="emit('openErrorDetail', log.id)"
>
<!-- Time & ID -->
<td class="px-6 py-4">
<div class="flex flex-col gap-0.5">
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200">
{{ formatDateTime(log.created_at).split(' ')[1] }}
</span>
<span
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
:title="log.request_id || log.client_request_id"
>
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
</span>
</div>
</td>
<!-- Context (Platform/Model) -->
<td class="px-6 py-4">
<div class="flex flex-col items-start gap-1.5">
<span
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ log.platform || '-' }}
</span>
<span
v-if="log.model"
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
:title="log.model"
>
{{ log.model }}
</span>
<div
v-if="log.group_id || log.account_id"
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
>
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
</div>
</div>
</td>
<!-- Status & Severity -->
<td class="px-6 py-4">
<div class="flex flex-wrap items-center gap-2">
<span
:class="[
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
getStatusClass(log.status_code)
]"
>
{{ log.status_code }}
</span>
<span
v-if="log.severity"
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]"
>
{{ log.severity }}
</span>
</div>
</td>
<!-- Message -->
<td class="px-6 py-4">
<div class="max-w-md lg:max-w-2xl">
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message">
{{ formatSmartMessage(log.message) || '-' }}
</p>
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
<div v-if="log.phase" class="flex items-center gap-1">
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ log.phase }}</span>
</div>
<div v-if="log.client_ip" class="flex items-center gap-1">
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
</div>
</div>
</div>
</td>
<!-- Latency -->
<td class="px-6 py-4 text-right">
<div class="flex flex-col items-end">
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
</span>
</div>
</td>
<!-- Actions -->
<td class="px-6 py-4 text-right" @click.stop>
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)">
{{ t('admin.ops.errorLog.details') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200, 500]"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Pagination from '@/components/common/Pagination.vue'
import type { OpsErrorLog } from '@/api/admin/ops'
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n()
interface Props {
rows: OpsErrorLog[]
total: number
loading: boolean
page: number
pageSize: number
}
interface Emits {
(e: 'openErrorDetail', id: number): void
(e: 'update:page', value: number): void
(e: 'update:pageSize', value: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
function getStatusClass(code: number): string {
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
}
function getLatencyClass(latency: number | null): string {
if (!latency) return 'text-gray-400'
if (latency > 10000) return 'text-red-600 font-black'
if (latency > 5000) return 'text-red-500 font-bold'
if (latency > 2000) return 'text-orange-500 font-medium'
return 'text-gray-600 dark:text-gray-400'
}
function formatSmartMessage(msg: string): string {
if (!msg) return ''
if (msg.startsWith('{') || msg.startsWith('[')) {
try {
const obj = JSON.parse(msg)
if (obj?.error?.message) return String(obj.error.message)
if (obj?.message) return String(obj.message)
if (obj?.detail) return String(obj.detail)
if (typeof obj === 'object') return JSON.stringify(obj).substring(0, 150)
} catch {
// ignore parse error
}
}
if (msg.includes('context deadline exceeded')) return 'context deadline exceeded'
if (msg.includes('connection refused')) return 'connection refused'
if (msg.toLowerCase().includes('rate limit')) return 'rate limit'
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
Filler,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip
} from 'chart.js'
import { Line } from 'vue-chartjs'
import type { OpsErrorTrendPoint } from '@/api/admin/ops'
import type { ChartState } from '../types'
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import EmptyState from '@/components/common/EmptyState.vue'
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
interface Props {
points: OpsErrorTrendPoint[]
loading: boolean
timeRange: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'openRequestErrors'): void
(e: 'openUpstreamErrors'): void
}>()
const { t } = useI18n()
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
const colors = computed(() => ({
red: '#ef4444',
redAlpha: '#ef444420',
purple: '#8b5cf6',
purpleAlpha: '#8b5cf620',
gray: '#9ca3af',
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const totalRequestErrors = computed(() =>
sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.business_limited_count ?? 0)))
)
const totalUpstreamErrors = computed(() =>
sumNumbers(
props.points.map((p) => (p.upstream_error_count_excl_429_529 ?? 0) + (p.upstream_429_count ?? 0) + (p.upstream_529_count ?? 0))
)
)
const totalDisplayed = computed(() =>
sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.upstream_error_count_excl_429_529 ?? 0) + (p.business_limited_count ?? 0)))
)
const hasRequestErrors = computed(() => totalRequestErrors.value > 0)
const hasUpstreamErrors = computed(() => totalUpstreamErrors.value > 0)
const chartData = computed(() => {
if (!props.points.length || totalDisplayed.value <= 0) return null
return {
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
datasets: [
{
label: t('admin.ops.errorsSla'),
data: props.points.map((p) => p.error_count_sla ?? 0),
borderColor: colors.value.red,
backgroundColor: colors.value.redAlpha,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHitRadius: 10
},
{
label: t('admin.ops.upstreamExcl429529'),
data: props.points.map((p) => p.upstream_error_count_excl_429_529 ?? 0),
borderColor: colors.value.purple,
backgroundColor: colors.value.purpleAlpha,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHitRadius: 10
},
{
label: t('admin.ops.businessLimited'),
data: props.points.map((p) => p.business_limited_count ?? 0),
borderColor: colors.value.gray,
backgroundColor: 'transparent',
borderDash: [6, 6],
fill: false,
tension: 0.35,
pointRadius: 0,
pointHitRadius: 10
}
]
}
})
const state = computed<ChartState>(() => {
if (chartData.value) return 'ready'
if (props.loading) return 'loading'
return 'empty'
})
const options = computed(() => {
const c = colors.value
return {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' as const },
plugins: {
legend: {
position: 'top' as const,
align: 'end' as const,
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
},
tooltip: {
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
borderColor: c.grid,
borderWidth: 1,
padding: 10,
displayColors: true
}
},
scales: {
x: {
type: 'category' as const,
grid: { display: false },
ticks: {
color: c.text,
font: { size: 10 },
maxTicksLimit: 8,
autoSkip: true,
autoSkipPadding: 10
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: { color: c.grid, borderDash: [4, 4] },
ticks: { color: c.text, font: { size: 10 }, precision: 0 }
}
}
}
})
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex shrink-0 items-center justify-between">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
/>
</svg>
{{ t('admin.ops.errorTrend') }}
<HelpTooltip :content="t('admin.ops.tooltips.errorTrend')" />
</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!hasRequestErrors"
@click="emit('openRequestErrors')"
>
{{ t('admin.ops.errorDetails.requestErrors') }}
</button>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!hasUpstreamErrors"
@click="emit('openUpstreamErrors')"
>
{{ t('admin.ops.errorDetails.upstreamErrors') }}
</button>
</div>
</div>
<div class="min-h-0 flex-1">
<Line v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
<div v-else class="flex h-full items-center justify-center">
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, BarElement, CategoryScale, Legend, LinearScale, Tooltip } from 'chart.js'
import { Bar } from 'vue-chartjs'
import type { OpsLatencyHistogramResponse } from '@/api/admin/ops'
import type { ChartState } from '../types'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import EmptyState from '@/components/common/EmptyState.vue'
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)
interface Props {
latencyData: OpsLatencyHistogramResponse | null
loading: boolean
}
const props = defineProps<Props>()
const { t } = useI18n()
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
const colors = computed(() => ({
blue: '#3b82f6',
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const hasData = computed(() => (props.latencyData?.total_requests ?? 0) > 0)
const state = computed<ChartState>(() => {
if (hasData.value) return 'ready'
if (props.loading) return 'loading'
return 'empty'
})
const chartData = computed(() => {
if (!props.latencyData || !hasData.value) return null
const c = colors.value
return {
labels: props.latencyData.buckets.map((b) => b.range),
datasets: [
{
label: t('admin.ops.requests'),
data: props.latencyData.buckets.map((b) => b.count),
backgroundColor: c.blue,
borderRadius: 4,
barPercentage: 0.6
}
]
}
})
const options = computed(() => {
const c = colors.value
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { display: false },
ticks: { color: c.text, font: { size: 10 } }
},
y: {
beginAtZero: true,
grid: { color: c.grid, borderDash: [4, 4] },
ticks: { color: c.text, font: { size: 10 } }
}
}
}
})
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.ops.latencyHistogram') }}
<HelpTooltip :content="t('admin.ops.tooltips.latencyHistogram')" />
</h3>
</div>
<div class="min-h-0 flex-1">
<Bar v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
<div v-else class="flex h-full items-center justify-center">
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsRequestDetailsParams, type OpsRequestDetail } from '@/api/admin/ops'
import { parseTimeRangeMinutes, formatDateTime } from '../utils/opsFormatters'
export interface OpsRequestDetailsPreset {
title: string
kind?: OpsRequestDetailsParams['kind']
sort?: OpsRequestDetailsParams['sort']
min_duration_ms?: number
max_duration_ms?: number
}
interface Props {
modelValue: boolean
timeRange: string
preset: OpsRequestDetailsPreset
platform?: string
groupId?: number | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'openErrorDetail', errorId: number): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const loading = ref(false)
const items = ref<OpsRequestDetail[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const close = () => emit('update:modelValue', false)
const rangeLabel = computed(() => {
const minutes = parseTimeRangeMinutes(props.timeRange)
if (minutes >= 60) return t('admin.ops.requestDetails.rangeHours', { n: Math.round(minutes / 60) })
return t('admin.ops.requestDetails.rangeMinutes', { n: minutes })
})
function buildTimeParams(): Pick<OpsRequestDetailsParams, 'start_time' | 'end_time'> {
const minutes = parseTimeRangeMinutes(props.timeRange)
const endTime = new Date()
const startTime = new Date(endTime.getTime() - minutes * 60 * 1000)
return {
start_time: startTime.toISOString(),
end_time: endTime.toISOString()
}
}
const fetchData = async () => {
if (!props.modelValue) return
loading.value = true
try {
const params: OpsRequestDetailsParams = {
...buildTimeParams(),
page: page.value,
page_size: pageSize.value,
kind: props.preset.kind ?? 'all',
sort: props.preset.sort ?? 'created_at_desc'
}
const platform = (props.platform || '').trim()
if (platform) params.platform = platform
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
if (typeof props.preset.min_duration_ms === 'number') params.min_duration_ms = props.preset.min_duration_ms
if (typeof props.preset.max_duration_ms === 'number') params.max_duration_ms = props.preset.max_duration_ms
const res = await opsAPI.listRequestDetails(params)
items.value = res.items || []
total.value = res.total || 0
} catch (e: any) {
console.error('[OpsRequestDetailsModal] Failed to fetch request details', e)
appStore.showError(e?.message || t('admin.ops.requestDetails.failedToLoad'))
items.value = []
total.value = 0
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
page.value = 1
fetchData()
}
}
)
watch(
() => [
props.timeRange,
props.platform,
props.groupId,
props.preset.kind,
props.preset.sort,
props.preset.min_duration_ms,
props.preset.max_duration_ms
],
() => {
if (!props.modelValue) return
page.value = 1
fetchData()
}
)
function handlePageChange(next: number) {
page.value = next
fetchData()
}
function handlePageSizeChange(next: number) {
pageSize.value = next
page.value = 1
fetchData()
}
async function handleCopyRequestId(requestId: string) {
const ok = await copyToClipboard(requestId, t('admin.ops.requestDetails.requestIdCopied'))
if (ok) return
// `useClipboard` already shows toast on failure; this keeps UX consistent with older ops modal.
appStore.showWarning(t('admin.ops.requestDetails.copyFailed'))
}
function openErrorDetail(errorId: number | null | undefined) {
if (!errorId) return
close()
emit('openErrorDetail', errorId)
}
const kindBadgeClass = (kind: string) => {
if (kind === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}
</script>
<template>
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
<template #default>
<div class="flex items-center justify-between mb-4">
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="fetchData"
>
{{ t('common.refresh') }}
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div class="flex flex-col items-center gap-3">
<svg class="h-8 w-8 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</span>
</div>
</div>
<!-- Table -->
<div v-else>
<div v-if="items.length === 0" class="rounded-xl border border-dashed border-gray-200 p-10 text-center dark:border-dark-700">
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.requestDetails.empty') }}</div>
<div class="mt-1 text-xs text-gray-400">{{ t('admin.ops.requestDetails.emptyHint') }}</div>
</div>
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.time') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.kind') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.platform') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.model') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.duration') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.status') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.requestId') }}
</th>
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="(row, idx) in items" :key="idx" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ formatDateTime(row.created_at) }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="kindBadgeClass(row.kind)">
{{ row.kind === 'error' ? t('admin.ops.requestDetails.kind.error') : t('admin.ops.requestDetails.kind.success') }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-200">
{{ (row.platform || 'unknown').toUpperCase() }}
</td>
<td class="max-w-[240px] truncate px-4 py-3 text-xs text-gray-600 dark:text-gray-300" :title="row.model || ''">
{{ row.model || '-' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ typeof row.duration_ms === 'number' ? `${row.duration_ms} ms` : '-' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ row.status_code ?? '-' }}
</td>
<td class="px-4 py-3">
<div v-if="row.request_id" class="flex items-center gap-2">
<span class="max-w-[220px] truncate font-mono text-[11px] text-gray-700 dark:text-gray-200" :title="row.request_id">
{{ row.request_id }}
</span>
<button
class="rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
@click="handleCopyRequestId(row.request_id)"
>
{{ t('admin.ops.requestDetails.copy') }}
</button>
</div>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<button
v-if="row.kind === 'error' && row.error_id"
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
@click="openErrorDetail(row.error_id)"
>
{{ t('admin.ops.requestDetails.viewError') }}
</button>
<span v-else class="text-xs text-gray-400">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="total"
:page="page"
:page-size="pageSize"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</div>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { opsAPI } from '@/api/admin/ops'
import type { OpsAlertRuntimeSettings } from '../types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const saving = ref(false)
const alertSettings = ref<OpsAlertRuntimeSettings | null>(null)
const showAlertEditor = ref(false)
const draftAlert = ref<OpsAlertRuntimeSettings | null>(null)
type ValidationResult = { valid: boolean; errors: string[] }
function normalizeSeverities(input: Array<string | null | undefined> | null | undefined): string[] {
if (!input || input.length === 0) return []
const allowed = new Set(['P0', 'P1', 'P2', 'P3'])
const out: string[] = []
const seen = new Set<string>()
for (const raw of input) {
const s = String(raw || '')
.trim()
.toUpperCase()
if (!s) continue
if (!allowed.has(s)) continue
if (seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
}
function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationResult {
const errors: string[] = []
const evalSeconds = settings.evaluation_interval_seconds
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
}
const lock = settings.distributed_lock
if (lock?.enabled) {
if (!lock.key || lock.key.trim().length < 3) {
errors.push(t('admin.ops.runtime.validation.lockKeyRequired'))
} else if (!lock.key.startsWith('ops:')) {
errors.push(t('admin.ops.runtime.validation.lockKeyPrefix', { prefix: 'ops:' }))
}
if (!Number.isFinite(lock.ttl_seconds) || lock.ttl_seconds < 1 || lock.ttl_seconds > 86400) {
errors.push(t('admin.ops.runtime.validation.lockTtlRange'))
}
}
// Silencing validation (alert-only)
const silencing = settings.silencing
if (silencing?.enabled) {
const until = (silencing.global_until_rfc3339 || '').trim()
if (until) {
const parsed = Date.parse(until)
if (!Number.isFinite(parsed)) errors.push(t('admin.ops.runtime.silencing.validation.timeFormat'))
}
const entries = Array.isArray(silencing.entries) ? silencing.entries : []
for (let idx = 0; idx < entries.length; idx++) {
const entry = entries[idx]
const untilEntry = (entry?.until_rfc3339 || '').trim()
if (!untilEntry) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilRequired'))
break
}
const parsedEntry = Date.parse(untilEntry)
if (!Number.isFinite(parsedEntry)) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilFormat'))
break
}
const ruleId = (entry as any)?.rule_id
if (typeof ruleId === 'number' && (!Number.isFinite(ruleId) || ruleId <= 0)) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.ruleIdPositive'))
break
}
if ((entry as any)?.severities) {
const raw = (entry as any).severities
const normalized = normalizeSeverities(Array.isArray(raw) ? raw : [raw])
if (Array.isArray(raw) && raw.length > 0 && normalized.length === 0) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.severitiesFormat'))
break
}
}
}
}
return { valid: errors.length === 0, errors }
}
const alertValidation = computed(() => {
if (!draftAlert.value) return { valid: true, errors: [] as string[] }
return validateRuntimeSettings(draftAlert.value)
})
async function loadSettings() {
loading.value = true
try {
alertSettings.value = await opsAPI.getAlertRuntimeSettings()
} catch (err: any) {
console.error('[OpsRuntimeSettingsCard] Failed to load runtime settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.loadFailed'))
} finally {
loading.value = false
}
}
function openAlertEditor() {
if (!alertSettings.value) return
draftAlert.value = JSON.parse(JSON.stringify(alertSettings.value))
// Backwards-compat: ensure nested settings exist even if API payload is older.
if (draftAlert.value) {
if (!draftAlert.value.distributed_lock) {
draftAlert.value.distributed_lock = { enabled: true, key: 'ops:alert:evaluator:leader', ttl_seconds: 30 }
}
if (!draftAlert.value.silencing) {
draftAlert.value.silencing = { enabled: false, global_until_rfc3339: '', global_reason: '', entries: [] }
}
if (!Array.isArray(draftAlert.value.silencing.entries)) {
draftAlert.value.silencing.entries = []
}
}
showAlertEditor.value = true
}
function addSilenceEntry() {
if (!draftAlert.value) return
if (!draftAlert.value.silencing) {
draftAlert.value.silencing = { enabled: true, global_until_rfc3339: '', global_reason: '', entries: [] }
}
if (!Array.isArray(draftAlert.value.silencing.entries)) {
draftAlert.value.silencing.entries = []
}
draftAlert.value.silencing.entries.push({
rule_id: undefined,
severities: [],
until_rfc3339: '',
reason: ''
})
}
function removeSilenceEntry(index: number) {
if (!draftAlert.value?.silencing?.entries) return
draftAlert.value.silencing.entries.splice(index, 1)
}
function updateSilenceEntryRuleId(index: number, raw: string) {
const entries = draftAlert.value?.silencing?.entries
if (!entries || !entries[index]) return
const trimmed = raw.trim()
if (!trimmed) {
delete (entries[index] as any).rule_id
return
}
const n = Number.parseInt(trimmed, 10)
;(entries[index] as any).rule_id = Number.isFinite(n) ? n : undefined
}
function updateSilenceEntrySeverities(index: number, raw: string) {
const entries = draftAlert.value?.silencing?.entries
if (!entries || !entries[index]) return
const parts = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
;(entries[index] as any).severities = normalizeSeverities(parts)
}
async function saveAlertSettings() {
if (!draftAlert.value) return
if (!alertValidation.value.valid) {
appStore.showError(alertValidation.value.errors[0] || t('admin.ops.runtime.validation.invalid'))
return
}
saving.value = true
try {
alertSettings.value = await opsAPI.updateAlertRuntimeSettings(draftAlert.value)
showAlertEditor.value = false
appStore.showSuccess(t('admin.ops.runtime.saveSuccess'))
} catch (err: any) {
console.error('[OpsRuntimeSettingsCard] Failed to save alert runtime settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<template>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.description') }}</p>
</div>
<button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
@click="loadSettings"
>
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.refresh') }}
</button>
</div>
<div v-if="!alertSettings" class="text-sm text-gray-500 dark:text-gray-400">
<span v-if="loading">{{ t('admin.ops.runtime.loading') }}</span>
<span v-else>{{ t('admin.ops.runtime.noData') }}</span>
</div>
<div v-else class="space-y-6">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.alertTitle') }}</h4>
<button class="btn btn-sm btn-secondary" @click="openAlertEditor">{{ t('common.edit') }}</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.runtime.evalIntervalSeconds') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ alertSettings.evaluation_interval_seconds }}s</span>
</div>
<div
v-if="alertSettings.silencing?.enabled && alertSettings.silencing.global_until_rfc3339"
class="text-xs text-gray-600 dark:text-gray-300 md:col-span-2"
>
{{ t('admin.ops.runtime.silencing.globalUntil') }}:
<span class="ml-1 font-mono text-gray-900 dark:text-white">{{ alertSettings.silencing.global_until_rfc3339 }}</span>
</div>
<details class="col-span-1 md:col-span-2">
<summary class="cursor-pointer text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{{ t('admin.ops.runtime.showAdvancedDeveloperSettings') }}
</summary>
<div class="mt-2 grid grid-cols-1 gap-3 rounded-lg bg-gray-100 p-3 dark:bg-dark-800 md:grid-cols-2">
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockEnabled') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.enabled }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockKey') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.key }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockTTLSeconds') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.ttl_seconds }}s</span>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<BaseDialog :show="showAlertEditor" :title="t('admin.ops.runtime.alertTitle')" width="extra-wide" @close="showAlertEditor = false">
<div v-if="draftAlert" class="space-y-4">
<div
v-if="!alertValidation.valid"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
>
<div class="font-bold">{{ t('admin.ops.runtime.validation.title') }}</div>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li v-for="msg in alertValidation.errors" :key="msg">{{ msg }}</li>
</ul>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.evalIntervalSeconds') }}</div>
<input
v-model.number="draftAlert.evaluation_interval_seconds"
type="number"
min="1"
max="86400"
class="input"
:aria-invalid="!alertValidation.valid"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draftAlert.silencing.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('admin.ops.runtime.silencing.enabled') }}</span>
</label>
<div v-if="draftAlert.silencing.enabled" class="mt-4 space-y-4">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.globalUntil') }}</div>
<input
v-model="draftAlert.silencing.global_until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.reason') }}</div>
<input
v-model="draftAlert.silencing.global_reason"
type="text"
class="input"
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
/>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.entries.title') }}</div>
<p class="text-[11px] text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.entries.hint') }}</p>
</div>
<button class="btn btn-sm btn-secondary" type="button" @click="addSilenceEntry">
{{ t('admin.ops.runtime.silencing.entries.add') }}
</button>
</div>
<div v-if="!draftAlert.silencing.entries?.length" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-900 dark:text-gray-400">
{{ t('admin.ops.runtime.silencing.entries.empty') }}
</div>
<div v-else class="mt-4 space-y-4">
<div
v-for="(entry, idx) in draftAlert.silencing.entries"
:key="idx"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="mb-3 flex items-center justify-between">
<div class="text-xs font-bold text-gray-900 dark:text-white">
{{ t('admin.ops.runtime.silencing.entries.entryTitle', { n: idx + 1 }) }}
</div>
<button class="btn btn-sm btn-danger" type="button" @click="removeSilenceEntry(idx)">{{ t('common.delete') }}</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.ruleId') }}</div>
<input
:value="typeof (entry as any).rule_id === 'number' ? String((entry as any).rule_id) : ''"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.entries.ruleIdPlaceholder')"
@input="updateSilenceEntryRuleId(idx, ($event.target as HTMLInputElement).value)"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.severities') }}</div>
<input
:value="Array.isArray((entry as any).severities) ? (entry as any).severities.join(', ') : ''"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.entries.severitiesPlaceholder')"
@input="updateSilenceEntrySeverities(idx, ($event.target as HTMLInputElement).value)"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.until') }}</div>
<input
v-model="(entry as any).until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.reason') }}</div>
<input
v-model="(entry as any).reason"
type="text"
class="input"
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<summary class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.ops.runtime.advancedSettingsSummary') }}</summary>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="inline-flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
<input v-model="draftAlert.distributed_lock.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('admin.ops.runtime.lockEnabled') }}</span>
</label>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockKey') }}</div>
<input v-model="draftAlert.distributed_lock.key" type="text" class="input text-xs font-mono" />
<p v-if="draftAlert.distributed_lock.enabled" class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.validation.lockKeyHint', { prefix: 'ops:' }) }}
</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockTTLSeconds') }}</div>
<input v-model.number="draftAlert.distributed_lock.ttl_seconds" type="number" min="1" max="86400" class="input text-xs font-mono" />
</div>
</div>
</details>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="showAlertEditor = false">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="saving || !alertValidation.valid" @click="saveAlertSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment