Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
839ab37d
Commit
839ab37d
authored
Jan 12, 2026
by
yangjianbo
Browse files
Merge branch 'main' of
https://github.com/mt21625457/aicodex2api
parents
9dd0ef18
fd8473f2
Changes
124
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
839ab37d
...
...
@@ -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,拥有完整的管理员权限
'
,
...
...
frontend/src/router/index.ts
View file @
839ab37d
...
...
@@ -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
'
,
...
...
frontend/src/stores/adminSettings.ts
0 → 100644
View file @
839ab37d
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
}
})
frontend/src/stores/index.ts
View file @
839ab37d
...
...
@@ -5,6 +5,7 @@
export
{
useAuthStore
}
from
'
./auth
'
export
{
useAppStore
}
from
'
./app
'
export
{
useAdminSettingsStore
}
from
'
./adminSettings
'
export
{
useSubscriptionStore
}
from
'
./subscriptions
'
export
{
useOnboardingStore
}
from
'
./onboarding
'
...
...
frontend/src/views/admin/SettingsView.vue
View file @
839ab37d
...
...
@@ -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
)
...
...
frontend/src/views/admin/ops/OpsDashboard.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
0 → 100644
View file @
839ab37d
<
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
}}
<
/spa
n
>
/{{ 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
:
6
px
;
}
.
custom
-
scrollbar
::
-
webkit
-
scrollbar
-
track
{
background
:
transparent
;
}
.
custom
-
scrollbar
::
-
webkit
-
scrollbar
-
thumb
{
background
-
color
:
rgba
(
156
,
163
,
175
,
0.3
);
border
-
radius
:
3
px
;
}
.
custom
-
scrollbar
::
-
webkit
-
scrollbar
-
thumb
:
hover
{
background
-
color
:
rgba
(
156
,
163
,
175
,
0.5
);
}
<
/style
>
frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
0 → 100644
View file @
839ab37d
<
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>
frontend/src/views/admin/ops/components/OpsDashboardSkeleton.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsEmailNotificationCard.vue
0 → 100644
View file @
839ab37d
<
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>
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsLatencyChart.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue
0 → 100644
View file @
839ab37d
<
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
>
frontend/src/views/admin/ops/components/OpsRuntimeSettingsCard.vue
0 → 100644
View file @
839ab37d
<
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
>
Prev
1
2
3
4
5
6
7
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment