feat(channel-monitor): redesign user dashboard as card grid
Reference check-cx UI: INTELLIGENCE MONITOR hero + 3-column card grid with 60-point timeline bars. Backend: - Add PrimaryPingLatencyMs + Timeline[60] to UserMonitorView - ListRecentHistoryForMonitors: batch CTE + ROW_NUMBER() window query - indexLatestByModel / indexAvailabilityByModel helpers Frontend: - 7 new components: ProviderIcon, MonitorMetricPair, MonitorAvailabilityRow, MonitorTimeline, MonitorHero, MonitorCard, MonitorCardGrid - ChannelStatusView 381→~180 lines (delegated to subcomponents) - AbortController reload concurrency protection - HSL 0-120° availability color mapping - Replace emoji with Icon component (bolt / globe) - i18n: monitorCommon.* shared namespace, channelStatus.hero.* Bump VERSION to 0.1.114.24
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
@@ -22,15 +24,26 @@ func NewChannelMonitorUserHandler(monitorService *service.ChannelMonitorService)
|
|||||||
// --- Response ---
|
// --- Response ---
|
||||||
|
|
||||||
type channelMonitorUserListItem struct {
|
type channelMonitorUserListItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
GroupName string `json:"group_name"`
|
GroupName string `json:"group_name"`
|
||||||
PrimaryModel string `json:"primary_model"`
|
PrimaryModel string `json:"primary_model"`
|
||||||
PrimaryStatus string `json:"primary_status"`
|
PrimaryStatus string `json:"primary_status"`
|
||||||
PrimaryLatencyMs *int `json:"primary_latency_ms"`
|
PrimaryLatencyMs *int `json:"primary_latency_ms"`
|
||||||
Availability7d float64 `json:"availability_7d"`
|
PrimaryPingLatencyMs *int `json:"primary_ping_latency_ms"`
|
||||||
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
|
Availability7d float64 `json:"availability_7d"`
|
||||||
|
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
|
||||||
|
Timeline []channelMonitorUserTimelinePoint `json:"timeline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。
|
||||||
|
// 仅用于用户视图 list 响应,admin 视图不使用。
|
||||||
|
type channelMonitorUserTimelinePoint struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMs *int `json:"latency_ms"`
|
||||||
|
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelMonitorUserDetailResponse struct {
|
type channelMonitorUserDetailResponse struct {
|
||||||
@@ -60,16 +73,27 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
|
|||||||
LatencyMs: e.LatencyMs,
|
LatencyMs: e.LatencyMs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
timeline := make([]channelMonitorUserTimelinePoint, 0, len(v.Timeline))
|
||||||
|
for _, p := range v.Timeline {
|
||||||
|
timeline = append(timeline, channelMonitorUserTimelinePoint{
|
||||||
|
Status: p.Status,
|
||||||
|
LatencyMs: p.LatencyMs,
|
||||||
|
PingLatencyMs: p.PingLatencyMs,
|
||||||
|
CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
return channelMonitorUserListItem{
|
return channelMonitorUserListItem{
|
||||||
ID: v.ID,
|
ID: v.ID,
|
||||||
Name: v.Name,
|
Name: v.Name,
|
||||||
Provider: v.Provider,
|
Provider: v.Provider,
|
||||||
GroupName: v.GroupName,
|
GroupName: v.GroupName,
|
||||||
PrimaryModel: v.PrimaryModel,
|
PrimaryModel: v.PrimaryModel,
|
||||||
PrimaryStatus: v.PrimaryStatus,
|
PrimaryStatus: v.PrimaryStatus,
|
||||||
PrimaryLatencyMs: v.PrimaryLatencyMs,
|
PrimaryLatencyMs: v.PrimaryLatencyMs,
|
||||||
Availability7d: v.Availability7d,
|
PrimaryPingLatencyMs: v.PrimaryPingLatencyMs,
|
||||||
ExtraModels: extras,
|
Availability7d: v.Availability7d,
|
||||||
|
ExtraModels: extras,
|
||||||
|
Timeline: timeline,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID in
|
|||||||
func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monitorID int64) ([]*service.ChannelMonitorLatest, error) {
|
func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monitorID int64) ([]*service.ChannelMonitorLatest, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT DISTINCT ON (model)
|
SELECT DISTINCT ON (model)
|
||||||
model, status, latency_ms, checked_at
|
model, status, latency_ms, ping_latency_ms, checked_at
|
||||||
FROM channel_monitor_histories
|
FROM channel_monitor_histories
|
||||||
WHERE monitor_id = $1
|
WHERE monitor_id = $1
|
||||||
ORDER BY model, checked_at DESC
|
ORDER BY model, checked_at DESC
|
||||||
@@ -257,19 +257,27 @@ func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monit
|
|||||||
out := make([]*service.ChannelMonitorLatest, 0)
|
out := make([]*service.ChannelMonitorLatest, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
l := &service.ChannelMonitorLatest{}
|
l := &service.ChannelMonitorLatest{}
|
||||||
var latency sql.NullInt64
|
var latency, ping sql.NullInt64
|
||||||
if err := rows.Scan(&l.Model, &l.Status, &latency, &l.CheckedAt); err != nil {
|
if err := rows.Scan(&l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan latest row: %w", err)
|
return nil, fmt.Errorf("scan latest row: %w", err)
|
||||||
}
|
}
|
||||||
if latency.Valid {
|
assignNullInt(&l.LatencyMs, latency)
|
||||||
v := int(latency.Int64)
|
assignNullInt(&l.PingLatencyMs, ping)
|
||||||
l.LatencyMs = &v
|
|
||||||
}
|
|
||||||
out = append(out, l)
|
out = append(out, l)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assignNullInt 把 sql.NullInt64 解包到 *int 指针目标(valid 才分配新 int)。
|
||||||
|
// 集中实现避免 latency / ping 两处重复 if latency.Valid { v := int(...) ... } 模板。
|
||||||
|
func assignNullInt(dst **int, n sql.NullInt64) {
|
||||||
|
if !n.Valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := int(n.Int64)
|
||||||
|
*dst = &v
|
||||||
|
}
|
||||||
|
|
||||||
// ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。
|
// ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。
|
||||||
// "可用" = status IN (operational, degraded)。
|
// "可用" = status IN (operational, degraded)。
|
||||||
func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
|
func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
|
||||||
@@ -338,7 +346,7 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context,
|
|||||||
}
|
}
|
||||||
const q = `
|
const q = `
|
||||||
SELECT DISTINCT ON (monitor_id, model)
|
SELECT DISTINCT ON (monitor_id, model)
|
||||||
monitor_id, model, status, latency_ms, checked_at
|
monitor_id, model, status, latency_ms, ping_latency_ms, checked_at
|
||||||
FROM channel_monitor_histories
|
FROM channel_monitor_histories
|
||||||
WHERE monitor_id = ANY($1)
|
WHERE monitor_id = ANY($1)
|
||||||
ORDER BY monitor_id, model, checked_at DESC
|
ORDER BY monitor_id, model, checked_at DESC
|
||||||
@@ -352,14 +360,12 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context,
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var monitorID int64
|
var monitorID int64
|
||||||
l := &service.ChannelMonitorLatest{}
|
l := &service.ChannelMonitorLatest{}
|
||||||
var latency sql.NullInt64
|
var latency, ping sql.NullInt64
|
||||||
if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &l.CheckedAt); err != nil {
|
if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan latest batch row: %w", err)
|
return nil, fmt.Errorf("scan latest batch row: %w", err)
|
||||||
}
|
}
|
||||||
if latency.Valid {
|
assignNullInt(&l.LatencyMs, latency)
|
||||||
v := int(latency.Int64)
|
assignNullInt(&l.PingLatencyMs, ping)
|
||||||
l.LatencyMs = &v
|
|
||||||
}
|
|
||||||
out[monitorID] = append(out[monitorID], l)
|
out[monitorID] = append(out[monitorID], l)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -368,6 +374,107 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRecentHistoryForMonitors 为多个 monitor 批量取各自"指定模型"最近 N 条历史(按 checked_at DESC,最新在前)。
|
||||||
|
// primaryModels[monitorID] 指定该监控要过滤的模型名;monitor 不在 primaryModels 中的记录不返回。
|
||||||
|
// 通过 CTE + unnest(两个 int8/text 数组) 构造 (monitor_id, model) 白名单,
|
||||||
|
// 再用 ROW_NUMBER() OVER (PARTITION BY monitor_id) 取各自前 N 条。
|
||||||
|
//
|
||||||
|
// 返回值:map[monitorID] -> []*ChannelMonitorHistoryEntry(不含 message,减少网络开销)。
|
||||||
|
// 空 ids / 空 primaryModels 返回空 map,不报错。
|
||||||
|
func (r *channelMonitorRepository) ListRecentHistoryForMonitors(
|
||||||
|
ctx context.Context,
|
||||||
|
ids []int64,
|
||||||
|
primaryModels map[int64]string,
|
||||||
|
perMonitorLimit int,
|
||||||
|
) (map[int64][]*service.ChannelMonitorHistoryEntry, error) {
|
||||||
|
out := make(map[int64][]*service.ChannelMonitorHistoryEntry, len(ids))
|
||||||
|
pairIDs, pairModels := buildMonitorModelPairs(ids, primaryModels)
|
||||||
|
if len(pairIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
perMonitorLimit = clampTimelineLimit(perMonitorLimit)
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
WITH targets AS (
|
||||||
|
SELECT unnest($1::bigint[]) AS monitor_id,
|
||||||
|
unnest($2::text[]) AS model
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT h.monitor_id,
|
||||||
|
h.status,
|
||||||
|
h.latency_ms,
|
||||||
|
h.ping_latency_ms,
|
||||||
|
h.checked_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY h.monitor_id ORDER BY h.checked_at DESC) AS rn
|
||||||
|
FROM channel_monitor_histories h
|
||||||
|
JOIN targets t
|
||||||
|
ON t.monitor_id = h.monitor_id AND t.model = h.model
|
||||||
|
)
|
||||||
|
SELECT monitor_id, status, latency_ms, ping_latency_ms, checked_at
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn <= $3
|
||||||
|
ORDER BY monitor_id, checked_at DESC
|
||||||
|
`
|
||||||
|
rows, err := r.db.QueryContext(ctx, q, pq.Array(pairIDs), pq.Array(pairModels), perMonitorLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query recent history batch: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var monitorID int64
|
||||||
|
entry := &service.ChannelMonitorHistoryEntry{}
|
||||||
|
var latency, ping sql.NullInt64
|
||||||
|
if err := rows.Scan(&monitorID, &entry.Status, &latency, &ping, &entry.CheckedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan recent history row: %w", err)
|
||||||
|
}
|
||||||
|
assignNullInt(&entry.LatencyMs, latency)
|
||||||
|
assignNullInt(&entry.PingLatencyMs, ping)
|
||||||
|
out[monitorID] = append(out[monitorID], entry)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMonitorModelPairs 基于 ids 过滤出有效的 (monitor_id, model) 对,model 为空时跳过。
|
||||||
|
// 保证两个数组长度一致且一一对应,供 unnest 展开。
|
||||||
|
func buildMonitorModelPairs(ids []int64, primaryModels map[int64]string) ([]int64, []string) {
|
||||||
|
if len(ids) == 0 || len(primaryModels) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
pairIDs := make([]int64, 0, len(ids))
|
||||||
|
pairModels := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
model, ok := primaryModels[id]
|
||||||
|
if !ok || strings.TrimSpace(model) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairIDs = append(pairIDs, id)
|
||||||
|
pairModels = append(pairModels, model)
|
||||||
|
}
|
||||||
|
return pairIDs, pairModels
|
||||||
|
}
|
||||||
|
|
||||||
|
// timelineLimit* 批量 timeline 查询的 perMonitorLimit 夹紧范围。
|
||||||
|
// 下限 1 表示至少返回最近一条;上限 200 控制单次响应体与 SQL 内存占用(ROW_NUMBER 窗口上限)。
|
||||||
|
const (
|
||||||
|
timelineLimitMin = 1
|
||||||
|
timelineLimitMax = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// clampTimelineLimit 把 perMonitorLimit 夹紧到 [timelineLimitMin, timelineLimitMax],避免非法值或超大查询。
|
||||||
|
func clampTimelineLimit(n int) int {
|
||||||
|
if n < timelineLimitMin {
|
||||||
|
return timelineLimitMin
|
||||||
|
}
|
||||||
|
if n > timelineLimitMax {
|
||||||
|
return timelineLimitMax
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
|
// ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
|
||||||
func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
|
func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
|
||||||
out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
|
out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ func (s *ChannelMonitorService) BatchMonitorStatusSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListUserView 用户只读视图:列出所有 enabled 监控的概览。
|
// ListUserView 用户只读视图:列出所有 enabled 监控的概览。
|
||||||
// 使用批量聚合接口避免 N+1:1 次查 monitors,1 次查 latest(所有 monitor),1 次查 availability。
|
// 使用批量聚合接口避免 N+1:
|
||||||
|
//
|
||||||
|
// 1 次查 monitors;
|
||||||
|
// 1 次批量 latest(含 ping_latency_ms);
|
||||||
|
// 1 次批量 7d availability;
|
||||||
|
// 1 次批量 timeline(主模型最近 N 条)。
|
||||||
func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonitorView, error) {
|
func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonitorView, error) {
|
||||||
monitors, err := s.repo.ListEnabled(ctx)
|
monitors, err := s.repo.ListEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,6 +64,21 @@ func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonito
|
|||||||
return []*UserMonitorView{}, nil
|
return []*UserMonitorView{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ids, primaryByID, extrasByID := collectMonitorIndexes(monitors)
|
||||||
|
summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID)
|
||||||
|
latestMap := s.batchLatest(ctx, ids)
|
||||||
|
timelineMap := s.batchTimeline(ctx, ids, primaryByID)
|
||||||
|
|
||||||
|
views := make([]*UserMonitorView, 0, len(monitors))
|
||||||
|
for _, m := range monitors {
|
||||||
|
primaryLatest := pickLatest(latestMap[m.ID], m.PrimaryModel)
|
||||||
|
views = append(views, buildUserViewFromSummary(m, summaries[m.ID], primaryLatest, timelineMap[m.ID]))
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectMonitorIndexes 把 monitors 列表按 ID 展开为聚合查询所需的三个索引结构。
|
||||||
|
func collectMonitorIndexes(monitors []*ChannelMonitor) ([]int64, map[int64]string, map[int64][]string) {
|
||||||
ids := make([]int64, 0, len(monitors))
|
ids := make([]int64, 0, len(monitors))
|
||||||
primaryByID := make(map[int64]string, len(monitors))
|
primaryByID := make(map[int64]string, len(monitors))
|
||||||
extrasByID := make(map[int64][]string, len(monitors))
|
extrasByID := make(map[int64][]string, len(monitors))
|
||||||
@@ -67,14 +87,44 @@ func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonito
|
|||||||
primaryByID[m.ID] = m.PrimaryModel
|
primaryByID[m.ID] = m.PrimaryModel
|
||||||
extrasByID[m.ID] = m.ExtraModels
|
extrasByID[m.ID] = m.ExtraModels
|
||||||
}
|
}
|
||||||
summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID)
|
return ids, primaryByID, extrasByID
|
||||||
|
}
|
||||||
|
|
||||||
views := make([]*UserMonitorView, 0, len(monitors))
|
// batchLatest 批量取 latest per model,失败仅日志(与现有 BatchMonitorStatusSummary 一致,不阻断列表渲染)。
|
||||||
for _, m := range monitors {
|
func (s *ChannelMonitorService) batchLatest(ctx context.Context, ids []int64) map[int64][]*ChannelMonitorLatest {
|
||||||
summary := summaries[m.ID]
|
latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids)
|
||||||
views = append(views, buildUserViewFromSummary(m, summary))
|
if err != nil {
|
||||||
|
slog.Warn("channel_monitor: user view batch latest failed", "error", err)
|
||||||
|
return map[int64][]*ChannelMonitorLatest{}
|
||||||
}
|
}
|
||||||
return views, nil
|
return latestMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchTimeline 批量取每个 monitor 主模型最近 monitorTimelineMaxPoints 条历史。
|
||||||
|
func (s *ChannelMonitorService) batchTimeline(
|
||||||
|
ctx context.Context,
|
||||||
|
ids []int64,
|
||||||
|
primaryByID map[int64]string,
|
||||||
|
) map[int64][]*ChannelMonitorHistoryEntry {
|
||||||
|
timelineMap, err := s.repo.ListRecentHistoryForMonitors(ctx, ids, primaryByID, monitorTimelineMaxPoints)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("channel_monitor: user view batch timeline failed", "error", err)
|
||||||
|
return map[int64][]*ChannelMonitorHistoryEntry{}
|
||||||
|
}
|
||||||
|
return timelineMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickLatest 从 latest 切片中挑出指定 model 对应项,未命中返回 nil。
|
||||||
|
func pickLatest(rows []*ChannelMonitorLatest, model string) *ChannelMonitorLatest {
|
||||||
|
if model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Model == model {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserDetail 用户只读视图:单个监控详情(每个模型 7d/15d/30d 可用率与平均延迟)。
|
// GetUserDetail 用户只读视图:单个监控详情(每个模型 7d/15d/30d 可用率与平均延迟)。
|
||||||
@@ -170,9 +220,15 @@ func buildStatusSummary(
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary 装填 UserMonitorView(无 IO)。
|
// buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary + 主模型 latest + timeline 装填 UserMonitorView(无 IO)。
|
||||||
func buildUserViewFromSummary(m *ChannelMonitor, summary MonitorStatusSummary) *UserMonitorView {
|
// primaryLatest 可能为 nil(该监控尚无历史);timelineEntries 可能为空。
|
||||||
return &UserMonitorView{
|
func buildUserViewFromSummary(
|
||||||
|
m *ChannelMonitor,
|
||||||
|
summary MonitorStatusSummary,
|
||||||
|
primaryLatest *ChannelMonitorLatest,
|
||||||
|
timelineEntries []*ChannelMonitorHistoryEntry,
|
||||||
|
) *UserMonitorView {
|
||||||
|
view := &UserMonitorView{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
Provider: m.Provider,
|
Provider: m.Provider,
|
||||||
@@ -182,7 +238,26 @@ func buildUserViewFromSummary(m *ChannelMonitor, summary MonitorStatusSummary) *
|
|||||||
PrimaryLatencyMs: summary.PrimaryLatencyMs,
|
PrimaryLatencyMs: summary.PrimaryLatencyMs,
|
||||||
Availability7d: summary.Availability7d,
|
Availability7d: summary.Availability7d,
|
||||||
ExtraModels: summary.ExtraModels,
|
ExtraModels: summary.ExtraModels,
|
||||||
|
Timeline: buildTimelinePoints(timelineEntries),
|
||||||
}
|
}
|
||||||
|
if primaryLatest != nil {
|
||||||
|
view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTimelinePoints 把 history entry 裁剪为 timeline 点(去除 message/ID/Model,减小响应体)。
|
||||||
|
func buildTimelinePoints(entries []*ChannelMonitorHistoryEntry) []UserMonitorTimelinePoint {
|
||||||
|
out := make([]UserMonitorTimelinePoint, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, UserMonitorTimelinePoint{
|
||||||
|
Status: e.Status,
|
||||||
|
LatencyMs: e.LatencyMs,
|
||||||
|
PingLatencyMs: e.PingLatencyMs,
|
||||||
|
CheckedAt: e.CheckedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeModelDetails 合并 latest + availability 三个窗口为 ModelDetail 列表。
|
// mergeModelDetails 合并 latest + availability 三个窗口为 ModelDetail 列表。
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ const (
|
|||||||
// MonitorHistoryMaxLimit 历史查询最大返回条数(handler 层共享)。
|
// MonitorHistoryMaxLimit 历史查询最大返回条数(handler 层共享)。
|
||||||
MonitorHistoryMaxLimit = 1000
|
MonitorHistoryMaxLimit = 1000
|
||||||
|
|
||||||
|
// monitorTimelineMaxPoints 用户视图 timeline 每个监控最多返回的历史点数。
|
||||||
|
monitorTimelineMaxPoints = 60
|
||||||
|
|
||||||
// monitorEndpointResolveTimeout validateEndpoint 解析 hostname 的最长耗时。
|
// monitorEndpointResolveTimeout validateEndpoint 解析 hostname 的最长耗时。
|
||||||
monitorEndpointResolveTimeout = 5 * time.Second
|
monitorEndpointResolveTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type ChannelMonitorRepository interface {
|
|||||||
// 批量聚合(admin/user list 用,避免 N+1)
|
// 批量聚合(admin/user list 用,避免 N+1)
|
||||||
ListLatestForMonitorIDs(ctx context.Context, ids []int64) (map[int64][]*ChannelMonitorLatest, error)
|
ListLatestForMonitorIDs(ctx context.Context, ids []int64) (map[int64][]*ChannelMonitorLatest, error)
|
||||||
ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*ChannelMonitorAvailability, error)
|
ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*ChannelMonitorAvailability, error)
|
||||||
|
// ListRecentHistoryForMonitors 批量取多个 monitor 各自主模型(primaryModels[monitorID])最近 perMonitorLimit 条历史。
|
||||||
|
// 返回的 entry 已按 checked_at DESC 排序(最新在前),不含 message 字段。
|
||||||
|
ListRecentHistoryForMonitors(ctx context.Context, ids []int64, primaryModels map[int64]string, perMonitorLimit int) (map[int64][]*ChannelMonitorHistoryEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMonitorService 渠道监控管理服务。
|
// ChannelMonitorService 渠道监控管理服务。
|
||||||
|
|||||||
@@ -72,15 +72,25 @@ type CheckResult struct {
|
|||||||
|
|
||||||
// UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。
|
// UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。
|
||||||
type UserMonitorView struct {
|
type UserMonitorView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Provider string
|
Provider string
|
||||||
GroupName string
|
GroupName string
|
||||||
PrimaryModel string
|
PrimaryModel string
|
||||||
PrimaryStatus string
|
PrimaryStatus string
|
||||||
PrimaryLatencyMs *int
|
PrimaryLatencyMs *int
|
||||||
Availability7d float64 // 0-100
|
PrimaryPingLatencyMs *int // 主模型最近一次 ping 延迟
|
||||||
ExtraModels []ExtraModelStatus
|
Availability7d float64 // 0-100
|
||||||
|
ExtraModels []ExtraModelStatus
|
||||||
|
Timeline []UserMonitorTimelinePoint // 主模型最近 N 个历史点(按 checked_at DESC,最新在前)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserMonitorTimelinePoint 用户视图 timeline 单点数据(去除 message 以减小响应体)。
|
||||||
|
type UserMonitorTimelinePoint struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMs *int `json:"latency_ms"`
|
||||||
|
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||||
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtraModelStatus 附加模型最近一次状态。
|
// ExtraModelStatus 附加模型最近一次状态。
|
||||||
@@ -134,10 +144,11 @@ type ChannelMonitorHistoryEntry struct {
|
|||||||
|
|
||||||
// ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。
|
// ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。
|
||||||
type ChannelMonitorLatest struct {
|
type ChannelMonitorLatest struct {
|
||||||
Model string
|
Model string
|
||||||
Status string
|
Status string
|
||||||
LatencyMs *int
|
LatencyMs *int
|
||||||
CheckedAt time.Time
|
PingLatencyMs *int
|
||||||
|
CheckedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。
|
// ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface UserMonitorExtraModel {
|
|||||||
latency_ms: number | null
|
latency_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitorTimelinePoint {
|
||||||
|
status: MonitorStatus
|
||||||
|
latency_ms: number | null
|
||||||
|
ping_latency_ms: number | null
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserMonitorView {
|
export interface UserMonitorView {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -22,8 +29,10 @@ export interface UserMonitorView {
|
|||||||
primary_model: string
|
primary_model: string
|
||||||
primary_status: MonitorStatus
|
primary_status: MonitorStatus
|
||||||
primary_latency_ms: number | null
|
primary_latency_ms: number | null
|
||||||
|
primary_ping_latency_ms: number | null
|
||||||
availability_7d: number
|
availability_7d: number
|
||||||
extra_models: UserMonitorExtraModel[]
|
extra_models: UserMonitorExtraModel[]
|
||||||
|
timeline: MonitorTimelinePoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMonitorListResponse {
|
export interface UserMonitorListResponse {
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
|
|
||||||
<HelpTooltip>
|
|
||||||
<template #trigger>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
|
||||||
:class="statusBadgeClass(row.primary_status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(row.primary_status) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="text-xs font-semibold text-gray-100">
|
|
||||||
{{ row.primary_model }}
|
|
||||||
<span
|
|
||||||
class="ml-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
|
||||||
:class="statusBadgeClass(row.primary_status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(row.primary_status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="(row.extra_models?.length ?? 0) === 0" class="text-[11px] text-gray-300">
|
|
||||||
{{ t('monitorCommon.extraModelsEmpty') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="space-y-1">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-400">
|
|
||||||
{{ t('monitorCommon.extraModelsHeader') }}
|
|
||||||
</div>
|
|
||||||
<table class="w-full text-left text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-gray-400">
|
|
||||||
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.model') }}</th>
|
|
||||||
<th class="py-0.5 pr-2 font-medium">{{ t('channelStatus.detailColumns.latestStatus') }}</th>
|
|
||||||
<th class="py-0.5 font-medium">{{ t('channelStatus.detailColumns.latestLatency') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="m in row.extra_models" :key="m.model">
|
|
||||||
<td class="py-0.5 pr-2 text-gray-100">{{ m.model }}</td>
|
|
||||||
<td class="py-0.5 pr-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px]"
|
|
||||||
:class="statusBadgeClass(m.status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(m.status) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="py-0.5 text-gray-100">{{ formatLatency(m.latency_ms) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HelpTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import type { UserMonitorView } from '@/api/channelMonitor'
|
|
||||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
|
||||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
row: UserMonitorView
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-3 flex items-end justify-between">
|
||||||
|
<div class="text-[11px] uppercase tracking-widest text-gray-400">
|
||||||
|
{{ windowLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-0.5">
|
||||||
|
<span
|
||||||
|
class="text-3xl font-bold tabular-nums leading-none"
|
||||||
|
:style="colorStyle"
|
||||||
|
>
|
||||||
|
{{ displayValue }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-base font-semibold leading-none"
|
||||||
|
:style="colorStyle"
|
||||||
|
>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="samplesLabel"
|
||||||
|
class="mt-1 text-[11px] text-gray-400 text-right"
|
||||||
|
>
|
||||||
|
{{ samplesLabel }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { hslForPct } from '@/composables/useChannelMonitorFormat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
windowLabel: string
|
||||||
|
value: number | null
|
||||||
|
samplesLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (props.value === null || Number.isNaN(props.value)) return t('monitorCommon.latencyEmpty')
|
||||||
|
return props.value.toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorStyle = computed(() => {
|
||||||
|
const colour = hslForPct(props.value)
|
||||||
|
return colour ? { color: colour } : { color: 'rgb(156 163 175)' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
128
frontend/src/components/user/monitor/MonitorCard.vue
Normal file
128
frontend/src/components/user/monitor/MonitorCard.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group text-left p-5 rounded-2xl min-h-[280px] w-full bg-white/70 backdrop-blur-xl border border-gray-200/80 shadow-card dark:bg-dark-800/60 dark:border-dark-700/70 hover:-translate-y-1 hover:shadow-card-hover dark:hover:border-primary-500/30 hover:border-gray-300 transition-all duration-300 ease-out flex flex-col"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<!-- Header: icon + name/model + status chip -->
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="w-9 h-9 rounded-xl ring-1 ring-black/5 dark:ring-white/10 grid place-items-center flex-shrink-0"
|
||||||
|
:class="[providerGradient(item.provider), providerTintClass]"
|
||||||
|
>
|
||||||
|
<ProviderIcon :provider="item.provider" :size="20" />
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-base font-semibold truncate text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0"
|
||||||
|
:class="providerBadgeClass(item.provider)"
|
||||||
|
>
|
||||||
|
{{ providerLabel(item.provider) }}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-xs truncate text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.primary_model }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.group_name"
|
||||||
|
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{{ item.group_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs font-semibold flex-shrink-0"
|
||||||
|
:class="statusBadgeClass(item.primary_status)"
|
||||||
|
>
|
||||||
|
{{ statusLabel(item.primary_status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics -->
|
||||||
|
<MonitorMetricPair
|
||||||
|
primary-icon="bolt"
|
||||||
|
:primary-label="t('monitorCommon.dialogLatency')"
|
||||||
|
:primary-value="formatLatency(item.primary_latency_ms)"
|
||||||
|
primary-unit="ms"
|
||||||
|
secondary-icon="globe"
|
||||||
|
:secondary-label="t('monitorCommon.endpointPing')"
|
||||||
|
:secondary-value="formatLatency(item.primary_ping_latency_ms)"
|
||||||
|
secondary-unit="ms"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
|
||||||
|
|
||||||
|
<!-- Availability row -->
|
||||||
|
<MonitorAvailabilityRow
|
||||||
|
:window-label="availabilityLabel"
|
||||||
|
:value="availabilityValue"
|
||||||
|
:samples-label="extraModelsCountLabel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<MonitorTimeline
|
||||||
|
:buckets="item.timeline"
|
||||||
|
:countdown-seconds="countdownSeconds"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { UserMonitorView } from '@/api/channelMonitor'
|
||||||
|
import {
|
||||||
|
useChannelMonitorFormat,
|
||||||
|
providerGradient,
|
||||||
|
} from '@/composables/useChannelMonitorFormat'
|
||||||
|
import ProviderIcon from './ProviderIcon.vue'
|
||||||
|
import MonitorMetricPair from './MonitorMetricPair.vue'
|
||||||
|
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
|
||||||
|
import MonitorTimeline from './MonitorTimeline.vue'
|
||||||
|
|
||||||
|
const PROVIDER_TINT: Record<string, string> = {
|
||||||
|
openai: 'text-emerald-600 dark:text-emerald-300',
|
||||||
|
anthropic: 'text-orange-600 dark:text-orange-300',
|
||||||
|
gemini: 'text-sky-600 dark:text-sky-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UserMonitorView
|
||||||
|
window: '7d' | '15d' | '30d'
|
||||||
|
availabilityValue: number | null
|
||||||
|
countdownSeconds: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
statusLabel,
|
||||||
|
statusBadgeClass,
|
||||||
|
providerLabel,
|
||||||
|
providerBadgeClass,
|
||||||
|
formatLatency,
|
||||||
|
} = useChannelMonitorFormat()
|
||||||
|
|
||||||
|
const providerTintClass = computed(() =>
|
||||||
|
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
|
||||||
|
)
|
||||||
|
|
||||||
|
const availabilityLabel = computed(() => {
|
||||||
|
const win = t(`channelStatus.windowTab.${props.window}`)
|
||||||
|
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const extraModelsCountLabel = computed(() => {
|
||||||
|
const count = props.item.extra_models?.length ?? 0
|
||||||
|
if (count === 0) return undefined
|
||||||
|
return t('monitorCommon.extraModelsCount', { n: count })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
81
frontend/src/components/user/monitor/MonitorCardGrid.vue
Normal file
81
frontend/src/components/user/monitor/MonitorCardGrid.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="loading && items.length === 0"
|
||||||
|
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="i in 6"
|
||||||
|
:key="i"
|
||||||
|
class="p-5 rounded-2xl min-h-[280px] bg-white/70 dark:bg-dark-800/60 border border-gray-200/80 dark:border-dark-700/70 animate-pulse"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-4 w-2/3 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<div class="h-3 w-1/2 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-6 w-16 rounded-full bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 grid grid-cols-2 gap-2">
|
||||||
|
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
|
||||||
|
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 h-5 w-full rounded bg-gray-100 dark:bg-dark-900/40"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
:title="t('channelStatus.empty.title')"
|
||||||
|
:description="t('channelStatus.empty.description')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<MonitorCard
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:window="window"
|
||||||
|
:availability-value="resolveAvailability(item)"
|
||||||
|
:countdown-seconds="countdownSeconds"
|
||||||
|
@click="emit('cardClick', item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { UserMonitorView, UserMonitorDetail } from '@/api/channelMonitor'
|
||||||
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
|
import MonitorCard from './MonitorCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: UserMonitorView[]
|
||||||
|
window: '7d' | '15d' | '30d'
|
||||||
|
countdownSeconds: number
|
||||||
|
loading: boolean
|
||||||
|
detailCache: Record<number, UserMonitorDetail>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'cardClick', item: UserMonitorView): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function resolveAvailability(item: UserMonitorView): number | null {
|
||||||
|
if (props.window === '7d') {
|
||||||
|
return item.availability_7d ?? null
|
||||||
|
}
|
||||||
|
const detail = props.detailCache[item.id]
|
||||||
|
if (!detail) return null
|
||||||
|
const primary = detail.models.find(m => m.model === item.primary_model)
|
||||||
|
if (!primary) return null
|
||||||
|
return props.window === '15d' ? primary.availability_15d ?? null : primary.availability_30d ?? null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
133
frontend/src/components/user/monitor/MonitorHero.vue
Normal file
133
frontend/src/components/user/monitor/MonitorHero.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<section class="pt-8 pb-10 md:pb-14">
|
||||||
|
<div class="text-xs font-medium tracking-widest uppercase text-gray-400 dark:text-gray-500 mb-4">
|
||||||
|
{{ t('channelStatus.hero.breadcrumb') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1
|
||||||
|
class="text-5xl md:text-6xl xl:text-7xl font-bold leading-[1.05] tracking-tight text-gray-900 dark:text-gray-50"
|
||||||
|
>
|
||||||
|
{{ t('channelStatus.hero.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-sm md:text-base text-gray-500 dark:text-gray-400 max-w-xl">
|
||||||
|
{{ t('channelStatus.hero.subtitleZh') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs md:text-sm italic opacity-80 text-gray-500 dark:text-gray-400 max-w-xl">
|
||||||
|
{{ t('channelStatus.hero.subtitleEn') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start md:items-end gap-2.5">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="opt in windowOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="window === opt.value"
|
||||||
|
class="px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
:class="window === opt.value
|
||||||
|
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
@click="emit('update:window', opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
|
||||||
|
:class="overallChipClass"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full mr-1.5"
|
||||||
|
:class="overallDotClass"
|
||||||
|
></span>
|
||||||
|
{{ overallLabel }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
|
||||||
|
:disabled="loading"
|
||||||
|
:title="t('common.refresh')"
|
||||||
|
@click="emit('refresh')"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums text-right">
|
||||||
|
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||||
|
|
||||||
|
export type MonitorWindow = '7d' | '15d' | '30d'
|
||||||
|
export type OverallStatus = 'operational' | 'degraded' | 'unavailable'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
overallStatus: OverallStatus
|
||||||
|
updatedAt: string | null
|
||||||
|
intervalSeconds: number
|
||||||
|
window: MonitorWindow
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:window', value: MonitorWindow): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { formatRelativeTime } = useChannelMonitorFormat()
|
||||||
|
|
||||||
|
const windowOptions = computed<{ value: MonitorWindow; label: string }[]>(() => [
|
||||||
|
{ value: '7d', label: t('channelStatus.windowTab.7d') },
|
||||||
|
{ value: '15d', label: t('channelStatus.windowTab.15d') },
|
||||||
|
{ value: '30d', label: t('channelStatus.windowTab.30d') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const overallLabel = computed(() => t(`channelStatus.overall.${props.overallStatus}`))
|
||||||
|
|
||||||
|
const overallChipClass = computed(() => {
|
||||||
|
switch (props.overallStatus) {
|
||||||
|
case 'operational':
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||||
|
case 'degraded':
|
||||||
|
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||||
|
case 'unavailable':
|
||||||
|
default:
|
||||||
|
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const overallDotClass = computed(() => {
|
||||||
|
switch (props.overallStatus) {
|
||||||
|
case 'operational':
|
||||||
|
return 'bg-emerald-500 animate-pulse'
|
||||||
|
case 'degraded':
|
||||||
|
return 'bg-amber-500 animate-pulse'
|
||||||
|
case 'unavailable':
|
||||||
|
default:
|
||||||
|
return 'bg-red-500 animate-pulse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedLabel = computed(() => {
|
||||||
|
if (!props.updatedAt) return t('monitorCommon.updatedAt', { time: '--' })
|
||||||
|
return t('monitorCommon.updatedAt', { time: formatRelativeTime(props.updatedAt) })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
45
frontend/src/components/user/monitor/MonitorMetricPair.vue
Normal file
45
frontend/src/components/user/monitor/MonitorMetricPair.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-5 grid grid-cols-2 gap-2">
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
<Icon :name="primaryIcon" size="xs" />
|
||||||
|
<span>{{ primaryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
|
||||||
|
{{ primaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ primaryUnit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
<Icon :name="secondaryIcon" size="xs" />
|
||||||
|
<span>{{ secondaryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
|
||||||
|
{{ secondaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ secondaryUnit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
primaryLabel: string
|
||||||
|
primaryValue: string
|
||||||
|
primaryUnit: string
|
||||||
|
primaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
|
||||||
|
secondaryLabel: string
|
||||||
|
secondaryValue: string
|
||||||
|
secondaryUnit: string
|
||||||
|
secondaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
113
frontend/src/components/user/monitor/MonitorTimeline.vue
Normal file
113
frontend/src/components/user/monitor/MonitorTimeline.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-4 pt-3 border-t border-gray-100 dark:border-dark-700/60">
|
||||||
|
<div
|
||||||
|
class="flex justify-between text-[10px] font-semibold uppercase tracking-widest text-gray-400 mb-2"
|
||||||
|
>
|
||||||
|
<span>{{ t('monitorCommon.history60pts', { n: length }) }}</span>
|
||||||
|
<span class="tabular-nums">{{ t('monitorCommon.nextUpdateIn', { n: countdownSeconds }) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="maintenance"
|
||||||
|
class="flex h-5 w-full items-center justify-center rounded border border-dashed border-gray-300 dark:border-dark-600 text-[10px] uppercase tracking-widest text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t('monitorCommon.maintenancePaused') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-end gap-[2px] h-5 w-full">
|
||||||
|
<div
|
||||||
|
v-for="(bar, idx) in displayBars"
|
||||||
|
:key="idx"
|
||||||
|
class="flex-1 min-w-[3px] rounded-sm"
|
||||||
|
:class="bar.colorClass"
|
||||||
|
:style="{ height: bar.heightPct + '%' }"
|
||||||
|
:title="bar.title"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-1 flex justify-between text-[9px] uppercase tracking-widest text-gray-400"
|
||||||
|
>
|
||||||
|
<span>{{ t('monitorCommon.past') }}</span>
|
||||||
|
<span>{{ t('monitorCommon.now') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { MonitorTimelinePoint } from '@/api/channelMonitor'
|
||||||
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
buckets?: MonitorTimelinePoint[]
|
||||||
|
countdownSeconds: number
|
||||||
|
length?: number
|
||||||
|
maintenance?: boolean
|
||||||
|
}>(), {
|
||||||
|
buckets: () => [],
|
||||||
|
length: 60,
|
||||||
|
maintenance: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { statusLabel, formatLatency, formatRelativeTime } = useChannelMonitorFormat()
|
||||||
|
|
||||||
|
interface Bar {
|
||||||
|
colorClass: string
|
||||||
|
heightPct: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_HEIGHT: Record<string, number> = {
|
||||||
|
operational: 100,
|
||||||
|
degraded: 70,
|
||||||
|
failed: 55,
|
||||||
|
error: 35,
|
||||||
|
empty: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
operational: 'bg-emerald-500',
|
||||||
|
degraded: 'bg-amber-500',
|
||||||
|
failed: 'bg-red-500',
|
||||||
|
error: 'bg-gray-400 dark:bg-dark-500',
|
||||||
|
empty: 'bg-gray-300 dark:bg-dark-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayBars = computed<Bar[]>(() => {
|
||||||
|
// Real points come newest-first; convert to oldest-first so the rightmost
|
||||||
|
// bar represents "now". Pad the left with empty placeholders to keep the
|
||||||
|
// bar count stable at `length`.
|
||||||
|
const real = [...(props.buckets ?? [])]
|
||||||
|
.slice(0, props.length)
|
||||||
|
.reverse()
|
||||||
|
|
||||||
|
const padCount = Math.max(0, props.length - real.length)
|
||||||
|
const bars: Bar[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < padCount; i += 1) {
|
||||||
|
bars.push({
|
||||||
|
colorClass: STATUS_COLOR.empty,
|
||||||
|
heightPct: STATUS_HEIGHT.empty,
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const point of real) {
|
||||||
|
const status = point.status as keyof typeof STATUS_HEIGHT
|
||||||
|
const colorClass = STATUS_COLOR[status] ?? STATUS_COLOR.empty
|
||||||
|
const heightPct = STATUS_HEIGHT[status] ?? STATUS_HEIGHT.empty
|
||||||
|
const latency = formatLatency(point.latency_ms)
|
||||||
|
const relative = formatRelativeTime(point.checked_at)
|
||||||
|
const label = statusLabel(point.status)
|
||||||
|
bars.push({
|
||||||
|
colorClass,
|
||||||
|
heightPct,
|
||||||
|
title: `${relative} · ${label} · ${latency}ms`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars
|
||||||
|
})
|
||||||
|
</script>
|
||||||
71
frontend/src/components/user/monitor/ProviderIcon.vue
Normal file
71
frontend/src/components/user/monitor/ProviderIcon.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="iconInfo"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-for="(p, idx) in iconInfo.paths"
|
||||||
|
:key="idx"
|
||||||
|
:d="p"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center justify-center font-bold text-gray-500"
|
||||||
|
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
|
||||||
|
>
|
||||||
|
{{ fallbackText }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Provider } from '@/api/admin/channelMonitor'
|
||||||
|
|
||||||
|
interface IconData {
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider SVG paths extracted from src/components/common/ModelIcon.vue (which
|
||||||
|
// in turn pulls from @lobehub/icons Mono.js). Keep in sync if upstream changes.
|
||||||
|
// SVG uses fill="currentColor" so the wrapper controls the icon tint.
|
||||||
|
const PROVIDER_ICONS: Record<Provider, IconData> = {
|
||||||
|
openai: {
|
||||||
|
paths: [
|
||||||
|
'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
paths: [
|
||||||
|
'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
paths: [
|
||||||
|
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
provider: Provider | string
|
||||||
|
size?: number
|
||||||
|
}>(), {
|
||||||
|
size: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconInfo = computed<IconData | null>(() => {
|
||||||
|
const key = props.provider as Provider
|
||||||
|
return PROVIDER_ICONS[key] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackText = computed(() =>
|
||||||
|
(props.provider || '?').charAt(0).toUpperCase()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* Centralises:
|
* Centralises:
|
||||||
* - status / provider label + badge class lookups
|
* - status / provider label + badge class lookups
|
||||||
* - latency / availability / percent number formatting
|
* - latency / availability / percent number formatting
|
||||||
|
* - dashboard-style helpers (HSL for availability, provider gradient, relative time)
|
||||||
*
|
*
|
||||||
* i18n keys live under `monitorCommon.*` so admin and user views share the
|
* i18n keys live under `monitorCommon.*` so admin and user views share the
|
||||||
* same translation source.
|
* same translation source.
|
||||||
@@ -23,6 +24,11 @@ import {
|
|||||||
|
|
||||||
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
|
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
|
||||||
|
|
||||||
|
/** Availability HSL hue multiplier: 0%=red(0) / 50%=yellow(60) / 100%=green(120). */
|
||||||
|
const HSL_HUE_PER_PERCENT = 1.2
|
||||||
|
const HSL_SATURATION = 72
|
||||||
|
const HSL_LIGHTNESS = 42
|
||||||
|
|
||||||
export interface AvailabilityRow {
|
export interface AvailabilityRow {
|
||||||
primary_status: MonitorStatus | ''
|
primary_status: MonitorStatus | ''
|
||||||
availability_7d: number | null | undefined
|
availability_7d: number | null | undefined
|
||||||
@@ -39,11 +45,11 @@ export function useChannelMonitorFormat() {
|
|||||||
function statusBadgeClass(s: MonitorStatus | ''): string {
|
function statusBadgeClass(s: MonitorStatus | ''): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case STATUS_OPERATIONAL:
|
case STATUS_OPERATIONAL:
|
||||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||||
case STATUS_DEGRADED:
|
case STATUS_DEGRADED:
|
||||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||||
case STATUS_FAILED:
|
case STATUS_FAILED:
|
||||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
|
||||||
case STATUS_ERROR:
|
case STATUS_ERROR:
|
||||||
default:
|
default:
|
||||||
return NEUTRAL_BADGE
|
return NEUTRAL_BADGE
|
||||||
@@ -60,11 +66,11 @@ export function useChannelMonitorFormat() {
|
|||||||
function providerBadgeClass(p: Provider | string): string {
|
function providerBadgeClass(p: Provider | string): string {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
case PROVIDER_OPENAI:
|
case PROVIDER_OPENAI:
|
||||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
|
||||||
case PROVIDER_ANTHROPIC:
|
case PROVIDER_ANTHROPIC:
|
||||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300'
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300'
|
||||||
case PROVIDER_GEMINI:
|
case PROVIDER_GEMINI:
|
||||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
|
||||||
default:
|
default:
|
||||||
return NEUTRAL_BADGE
|
return NEUTRAL_BADGE
|
||||||
}
|
}
|
||||||
@@ -85,6 +91,20 @@ export function useChannelMonitorFormat() {
|
|||||||
return formatPercent(row.availability_7d)
|
return formatPercent(row.availability_7d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return t('monitorCommon.latencyEmpty')
|
||||||
|
const ts = Date.parse(iso)
|
||||||
|
if (Number.isNaN(ts)) return t('monitorCommon.latencyEmpty')
|
||||||
|
const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||||
|
if (diffSec < 60) return t('monitorCommon.relativeSecondsAgo', { n: diffSec })
|
||||||
|
const diffMin = Math.floor(diffSec / 60)
|
||||||
|
if (diffMin < 60) return t('monitorCommon.relativeMinutesAgo', { n: diffMin })
|
||||||
|
const diffHour = Math.floor(diffMin / 60)
|
||||||
|
if (diffHour < 24) return t('monitorCommon.relativeHoursAgo', { n: diffHour })
|
||||||
|
const diffDay = Math.floor(diffHour / 24)
|
||||||
|
return t('monitorCommon.relativeDaysAgo', { n: diffDay })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusBadgeClass,
|
statusBadgeClass,
|
||||||
@@ -93,5 +113,33 @@ export function useChannelMonitorFormat() {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatAvailability,
|
formatAvailability,
|
||||||
|
formatRelativeTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map availability percent to an HSL colour (red -> yellow -> green).
|
||||||
|
* Returns undefined for null/NaN so callers can fall back to a neutral colour.
|
||||||
|
*/
|
||||||
|
export function hslForPct(pct: number | null | undefined): string | undefined {
|
||||||
|
if (pct === null || pct === undefined || Number.isNaN(pct)) return undefined
|
||||||
|
const clamped = Math.max(0, Math.min(100, pct))
|
||||||
|
const hue = clamped * HSL_HUE_PER_PERCENT
|
||||||
|
return `hsl(${hue} ${HSL_SATURATION}% ${HSL_LIGHTNESS}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind gradient class for the provider icon tile background.
|
||||||
|
*/
|
||||||
|
export function providerGradient(provider: string): string {
|
||||||
|
switch (provider) {
|
||||||
|
case PROVIDER_OPENAI:
|
||||||
|
return 'bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-500/10 dark:to-emerald-500/20'
|
||||||
|
case PROVIDER_ANTHROPIC:
|
||||||
|
return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20'
|
||||||
|
case PROVIDER_GEMINI:
|
||||||
|
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
|
||||||
|
default:
|
||||||
|
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -867,7 +867,22 @@ export default {
|
|||||||
},
|
},
|
||||||
extraModelsHeader: 'Extra Models',
|
extraModelsHeader: 'Extra Models',
|
||||||
extraModelsEmpty: 'No extra models',
|
extraModelsEmpty: 'No extra models',
|
||||||
latencyEmpty: '-'
|
latencyEmpty: '-',
|
||||||
|
availabilityPrefix: 'Availability',
|
||||||
|
dialogLatency: 'Dialog Latency',
|
||||||
|
endpointPing: 'Endpoint PING',
|
||||||
|
history60pts: 'HISTORY ({n} PTS)',
|
||||||
|
nextUpdateIn: 'NEXT UPDATE IN {n}s',
|
||||||
|
past: 'PAST',
|
||||||
|
now: 'NOW',
|
||||||
|
maintenancePaused: 'Maintenance · timeline paused',
|
||||||
|
extraModelsCount: '+ {n} models',
|
||||||
|
pollEvery: '{n}s polling',
|
||||||
|
updatedAt: 'Updated {time}',
|
||||||
|
relativeSecondsAgo: '{n}s ago',
|
||||||
|
relativeMinutesAgo: '{n}m ago',
|
||||||
|
relativeHoursAgo: '{n}h ago',
|
||||||
|
relativeDaysAgo: '{n}d ago'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Channel Status (user-facing read-only view)
|
// Channel Status (user-facing read-only view)
|
||||||
@@ -880,6 +895,22 @@ export default {
|
|||||||
detailLoadError: 'Failed to load channel detail',
|
detailLoadError: 'Failed to load channel detail',
|
||||||
detailTitle: 'Channel Detail',
|
detailTitle: 'Channel Detail',
|
||||||
closeDetail: 'Close',
|
closeDetail: 'Close',
|
||||||
|
hero: {
|
||||||
|
breadcrumb: 'CHANNEL · STATUS',
|
||||||
|
title: 'INTELLIGENCE MONITOR',
|
||||||
|
subtitleZh: 'Real-time tracking of availability, latency and status for leading AI endpoints.',
|
||||||
|
subtitleEn: 'Advanced performance metrics for next-gen intelligence.'
|
||||||
|
},
|
||||||
|
windowTab: {
|
||||||
|
'7d': '7 days',
|
||||||
|
'15d': '15 days',
|
||||||
|
'30d': '30 days'
|
||||||
|
},
|
||||||
|
overall: {
|
||||||
|
operational: 'OPERATIONAL',
|
||||||
|
degraded: 'DEGRADED',
|
||||||
|
unavailable: 'UNAVAILABLE'
|
||||||
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
|
|||||||
@@ -871,7 +871,22 @@ export default {
|
|||||||
},
|
},
|
||||||
extraModelsHeader: '附加模型',
|
extraModelsHeader: '附加模型',
|
||||||
extraModelsEmpty: '无附加模型',
|
extraModelsEmpty: '无附加模型',
|
||||||
latencyEmpty: '-'
|
latencyEmpty: '-',
|
||||||
|
availabilityPrefix: '可用性',
|
||||||
|
dialogLatency: '对话延迟',
|
||||||
|
endpointPing: '端点 PING',
|
||||||
|
history60pts: '近 {n} 次记录',
|
||||||
|
nextUpdateIn: '{n}s 后刷新',
|
||||||
|
past: 'PAST',
|
||||||
|
now: 'NOW',
|
||||||
|
maintenancePaused: '维护中 · 已暂停时间线采集',
|
||||||
|
extraModelsCount: '+ {n} 模型',
|
||||||
|
pollEvery: '{n}s 轮询',
|
||||||
|
updatedAt: '更新于 {time}',
|
||||||
|
relativeSecondsAgo: '{n} 秒前',
|
||||||
|
relativeMinutesAgo: '{n} 分钟前',
|
||||||
|
relativeHoursAgo: '{n} 小时前',
|
||||||
|
relativeDaysAgo: '{n} 天前'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Channel Status (user-facing read-only view)
|
// Channel Status (user-facing read-only view)
|
||||||
@@ -884,6 +899,22 @@ export default {
|
|||||||
detailLoadError: '加载渠道详情失败',
|
detailLoadError: '加载渠道详情失败',
|
||||||
detailTitle: '渠道详情',
|
detailTitle: '渠道详情',
|
||||||
closeDetail: '关闭',
|
closeDetail: '关闭',
|
||||||
|
hero: {
|
||||||
|
breadcrumb: '渠道 · 状态',
|
||||||
|
title: 'INTELLIGENCE MONITOR',
|
||||||
|
subtitleZh: '实时追踪各大 AI 模型对话接口的可用性、延迟与官方服务状态。',
|
||||||
|
subtitleEn: 'Advanced performance metrics for next-gen intelligence.'
|
||||||
|
},
|
||||||
|
windowTab: {
|
||||||
|
'7d': '7 天',
|
||||||
|
'15d': '15 天',
|
||||||
|
'30d': '30 天'
|
||||||
|
},
|
||||||
|
overall: {
|
||||||
|
operational: 'OPERATIONAL',
|
||||||
|
degraded: 'DEGRADED',
|
||||||
|
unavailable: 'UNAVAILABLE'
|
||||||
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
provider: '供应商',
|
provider: '供应商',
|
||||||
|
|||||||
@@ -1,93 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<TablePageLayout>
|
<MonitorHero
|
||||||
<template #filters>
|
:overall-status="overallStatus"
|
||||||
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
|
:updated-at="updatedAt"
|
||||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
|
||||||
<div class="relative w-full sm:w-64">
|
:window="currentWindow"
|
||||||
<Icon
|
:loading="loading"
|
||||||
name="search"
|
@update:window="handleWindowChange"
|
||||||
size="md"
|
@refresh="manualReload"
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
/>
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('channelStatus.searchPlaceholder')"
|
|
||||||
class="input pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
<MonitorCardGrid
|
||||||
v-model="providerFilter"
|
:items="items"
|
||||||
:options="providerFilterOptions"
|
:window="currentWindow"
|
||||||
:placeholder="t('channelStatus.allProviders')"
|
:countdown-seconds="countdown"
|
||||||
class="w-44"
|
:loading="loading"
|
||||||
/>
|
:detail-cache="detailCache"
|
||||||
</div>
|
@card-click="openDetail"
|
||||||
|
/>
|
||||||
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
|
|
||||||
<button
|
|
||||||
@click="reload"
|
|
||||||
:disabled="loading"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
:title="t('common.refresh')"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #table>
|
|
||||||
<DataTable :columns="columns" :data="filteredItems" :loading="loading">
|
|
||||||
<template #cell-name="{ row }">
|
|
||||||
<button
|
|
||||||
@click="openDetail(row)"
|
|
||||||
class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
|
||||||
>
|
|
||||||
{{ row.name }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-provider="{ row }">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium"
|
|
||||||
:class="providerBadgeClass(row.provider)"
|
|
||||||
>
|
|
||||||
{{ providerLabel(row.provider) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-group_name="{ value }">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-primary_model="{ row }">
|
|
||||||
<MonitorPrimaryModelCell :row="row" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-availability_7d="{ row }">
|
|
||||||
<span class="text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatAvailability(row) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-latency="{ row }">
|
|
||||||
<span class="text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatLatency(row.primary_latency_ms) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty>
|
|
||||||
<EmptyState
|
|
||||||
:title="t('channelStatus.empty.title')"
|
|
||||||
:description="t('channelStatus.empty.description')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</template>
|
|
||||||
</TablePageLayout>
|
|
||||||
|
|
||||||
<MonitorDetailDialog
|
<MonitorDetailDialog
|
||||||
:show="showDetail"
|
:show="showDetail"
|
||||||
@@ -99,79 +29,54 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
import {
|
import {
|
||||||
list as listChannelMonitorViews,
|
list as listChannelMonitorViews,
|
||||||
type Provider,
|
status as fetchChannelMonitorDetail,
|
||||||
type UserMonitorView,
|
type UserMonitorView,
|
||||||
|
type UserMonitorDetail,
|
||||||
} from '@/api/channelMonitor'
|
} from '@/api/channelMonitor'
|
||||||
import type { Column } from '@/components/common/types'
|
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
import MonitorHero, {
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
type MonitorWindow,
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
type OverallStatus,
|
||||||
import Select from '@/components/common/Select.vue'
|
} from '@/components/user/monitor/MonitorHero.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
|
||||||
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
|
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
|
||||||
import MonitorPrimaryModelCell from '@/components/user/MonitorPrimaryModelCell.vue'
|
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
|
||||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
|
||||||
import {
|
|
||||||
PROVIDER_OPENAI,
|
|
||||||
PROVIDER_ANTHROPIC,
|
|
||||||
PROVIDER_GEMINI,
|
|
||||||
} from '@/constants/channelMonitor'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const {
|
|
||||||
providerLabel,
|
|
||||||
providerBadgeClass,
|
|
||||||
formatLatency,
|
|
||||||
formatAvailability,
|
|
||||||
} = useChannelMonitorFormat()
|
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
const items = ref<UserMonitorView[]>([])
|
const items = ref<UserMonitorView[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchQuery = ref('')
|
const updatedAt = ref<string | null>(null)
|
||||||
const providerFilter = ref<Provider | ''>('')
|
const currentWindow = ref<MonitorWindow>('7d')
|
||||||
|
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
|
||||||
|
const countdown = ref(DEFAULT_INTERVAL_SECONDS)
|
||||||
|
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const detailTarget = ref<UserMonitorView | null>(null)
|
const detailTarget = ref<UserMonitorView | null>(null)
|
||||||
|
|
||||||
// ── Options ──
|
let countdownTimer: number | undefined
|
||||||
const providerFilterOptions = computed(() => [
|
let abortController: AbortController | null = null
|
||||||
{ value: '', label: t('channelStatus.allProviders') },
|
|
||||||
{ value: PROVIDER_OPENAI, label: providerLabel(PROVIDER_OPENAI) },
|
|
||||||
{ value: PROVIDER_ANTHROPIC, label: providerLabel(PROVIDER_ANTHROPIC) },
|
|
||||||
{ value: PROVIDER_GEMINI, label: providerLabel(PROVIDER_GEMINI) },
|
|
||||||
])
|
|
||||||
|
|
||||||
// ── Columns ──
|
// ── Computed ──
|
||||||
const columns = computed<Column[]>(() => [
|
const overallStatus = computed<OverallStatus>(() => {
|
||||||
{ key: 'name', label: t('channelStatus.columns.name'), sortable: false },
|
if (items.value.length === 0) return 'operational'
|
||||||
{ key: 'provider', label: t('channelStatus.columns.provider'), sortable: false },
|
let hasFailure = false
|
||||||
{ key: 'group_name', label: t('channelStatus.columns.groupName'), sortable: false },
|
let hasDegraded = false
|
||||||
{ key: 'primary_model', label: t('channelStatus.columns.primaryModel'), sortable: false },
|
for (const it of items.value) {
|
||||||
{ key: 'availability_7d', label: t('channelStatus.columns.availability7d'), sortable: false },
|
if (it.primary_status === 'failed' || it.primary_status === 'error') hasFailure = true
|
||||||
{ key: 'latency', label: t('channelStatus.columns.latency'), sortable: false },
|
else if (it.primary_status !== STATUS_OPERATIONAL) hasDegraded = true
|
||||||
])
|
}
|
||||||
|
if (hasFailure) return 'unavailable'
|
||||||
// ── Filtered data ──
|
if (hasDegraded) return 'degraded'
|
||||||
const filteredItems = computed(() => {
|
return 'operational'
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
|
||||||
return items.value.filter(it => {
|
|
||||||
if (providerFilter.value && it.provider !== providerFilter.value) return false
|
|
||||||
if (!q) return true
|
|
||||||
return (
|
|
||||||
it.name.toLowerCase().includes(q) ||
|
|
||||||
(it.group_name || '').toLowerCase().includes(q) ||
|
|
||||||
it.primary_model.toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailTitle = computed(() => {
|
const detailTitle = computed(() => {
|
||||||
@@ -179,18 +84,58 @@ const detailTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Loaders ──
|
// ── Loaders ──
|
||||||
async function reload() {
|
async function reload(silent = false) {
|
||||||
loading.value = true
|
if (abortController) abortController.abort()
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abortController = ctrl
|
||||||
|
if (!silent) loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await listChannelMonitorViews()
|
const res = await listChannelMonitorViews({ signal: ctrl.signal })
|
||||||
|
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||||
items.value = res.items || []
|
items.value = res.items || []
|
||||||
|
updatedAt.value = new Date().toISOString()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
const e = err as { name?: string; code?: string }
|
||||||
|
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
||||||
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
|
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (abortController === ctrl) {
|
||||||
|
if (!silent) loading.value = false
|
||||||
|
countdown.value = DEFAULT_INTERVAL_SECONDS
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function manualReload() {
|
||||||
|
await reload(false)
|
||||||
|
// After base reload, refresh any cached detail records so non-7d availability
|
||||||
|
// values stay in sync without forcing the user to switch tabs again.
|
||||||
|
if (currentWindow.value !== '7d') {
|
||||||
|
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id: number, force = false) {
|
||||||
|
if (!force && detailCache[id]) return
|
||||||
|
try {
|
||||||
|
detailCache[id] = await fetchChannelMonitorDetail(id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDetailsForWindow() {
|
||||||
|
if (currentWindow.value === '7d') return
|
||||||
|
await Promise.all(items.value.map(it => loadDetail(it.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
|
async function handleWindowChange(value: MonitorWindow) {
|
||||||
|
currentWindow.value = value
|
||||||
|
await ensureDetailsForWindow()
|
||||||
|
}
|
||||||
|
|
||||||
function openDetail(row: UserMonitorView) {
|
function openDetail(row: UserMonitorView) {
|
||||||
detailTarget.value = row
|
detailTarget.value = row
|
||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
@@ -201,8 +146,28 @@ function closeDetail() {
|
|||||||
detailTarget.value = null
|
detailTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Polling ──
|
||||||
|
function tick() {
|
||||||
|
if (countdown.value <= 1) {
|
||||||
|
void reload(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
countdown.value -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(items, () => {
|
||||||
|
// Lazily load detail entries when window requires it and the list refreshes.
|
||||||
|
void ensureDetailsForWindow()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reload()
|
void reload(false)
|
||||||
|
countdownTimer = setInterval(tick, 1000) as unknown as number
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (countdownTimer !== undefined) clearInterval(countdownTimer)
|
||||||
|
if (abortController) abortController.abort()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user