From d2e03ab7e83f4ed0387afa7c32c8184e65f4381d Mon Sep 17 00:00:00 2001 From: cneicy Date: Sat, 26 Sep 2026 14:49:12 +0800 Subject: [PATCH] fix: follow live request results and prioritize usable groups --- CUSTOMIZATION.md | 10 ++- Dockerfile.compact-uptime | 4 +- Dockerfile.local-release | 2 +- VERSION | 2 +- controller/live_status_test.go | 63 ++++++++++--- controller/uptime_kuma.go | 40 ++++++--- pkg/perf_metrics/live_status.go | 62 ++++++++----- pkg/perf_metrics/live_status_test.go | 88 ++++++++++++++----- pkg/perf_metrics/metrics.go | 4 +- .../__tests__/status-page.test.tsx | 25 ++++-- .../group-status/__tests__/status.test.ts | 27 ++++++ web/src/features/group-status/lib/status.ts | 16 +++- 12 files changed, 257 insertions(+), 86 deletions(-) diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index a385098..4f45816 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -1,6 +1,6 @@ # 自定义版本说明 -当前版本为 v1.0.0-rc.38-custom.43-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。 +当前版本为 v1.0.0-rc.38-custom.44-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。 ## 主要定制 @@ -11,13 +11,13 @@ - 独立公开 uptime 页,使用 shadcn/ui,支持分组别名、隐藏分组和管理员重置当前分组检测记录。 - 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。 - 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。 -- 两页共用分组行布局,正常状态优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。 +- 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。 - 鹈鹕检测通过 manxue.ai 访客接口请求公开作品,保存可跳转的作品链接;连接失败时本地生成并标记为不确定。 - 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。 ## 从源码构建 - docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.43-20260926 . + docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.44-20260926 . 此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。 @@ -30,3 +30,7 @@ 升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。 公开作品链接受 manxue.ai 的保留期限约束;没有公开链接的历史作品继续使用本站预览。 + +## custom.44 current availability + +最近 15 分钟内,以最新完成的真实用户请求结果决定分组状态:成功为正常,上游服务失败为异常;抽检不覆盖这段时间的真实使用结果。客户端取消、参数错误和本地额度不足不改变分组健康状态。没有近期真实请求时使用最近一次独立探活,超过 30 分钟未探活显示未知。禁用分组保持不可用。指纹、鹈鹕和小时请求成功率保留为历史统计,不直接决定当前状态。正常与性能下降分组一起优先排列,同类中 Astra 优先。 diff --git a/Dockerfile.compact-uptime b/Dockerfile.compact-uptime index 78b328e..1ea2e1f 100644 --- a/Dockerfile.compact-uptime +++ b/Dockerfile.compact-uptime @@ -1,3 +1,3 @@ -FROM local/new-api:v1.0.0-rc.38-custom.42-20260926 +FROM local/new-api:v1.0.0-rc.38-custom.43-20260926 COPY new-api /new-api -LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.43-20260926" +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926" diff --git a/Dockerfile.local-release b/Dockerfile.local-release index e1d55e2..fd9b3c9 100644 --- a/Dockerfile.local-release +++ b/Dockerfile.local-release @@ -25,7 +25,7 @@ RUN apt-get update \ COPY --from=build /build/new-api / COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/ -LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.43-20260926" +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926" EXPOSE 3000 WORKDIR /data ENTRYPOINT ["/new-api"] diff --git a/VERSION b/VERSION index 9e7131b..5a8c4d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0-rc.38-custom.43-20260926 \ No newline at end of file +v1.0.0-rc.38-custom.44-20260926 \ No newline at end of file diff --git a/controller/live_status_test.go b/controller/live_status_test.go index 8d1e286..8281493 100644 --- a/controller/live_status_test.go +++ b/controller/live_status_test.go @@ -1,28 +1,65 @@ package controller import ( + perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" + "github.com/QuantumNous/new-api/service" "github.com/stretchr/testify/assert" "testing" - "time" ) -func TestSuccessfulUserRequestRestoresStatus(t *testing.T) { - now := time.Now().Unix() +func TestCurrentGroupStatus(t *testing.T) { + const now int64 = 10000 for _, tc := range []struct { - name string - success, probe int64 - disabled bool - want int + name string + success int64 + probes []service.ProbeHistoryPoint + disabled bool + want int + source string + at int64 }{ - {"fresh user overrides failed probe", now, now - 10, false, 1}, - {"newer failed probe wins", now - 10, now, false, 0}, - {"expired success", now - 901, now - 1000, false, 0}, - {"disabled remains disabled", now, now - 10, true, 0}, + {"fresh user overrides older failed probe", now, []service.ProbeHistoryPoint{{At: now - 10, Status: 0}}, false, 1, "requests", now}, + {"newer failed probe cannot erase recent user success", now - 10, []service.ProbeHistoryPoint{{At: now, Status: 0}}, false, 1, "requests", now - 10}, + {"working primary with failing backup stays normal", now - 60, []service.ProbeHistoryPoint{{At: now, Status: 2}}, false, 1, "requests", now - 60}, + {"success remains fresh for full fifteen minutes", now - 900, nil, false, 1, "requests", now - 900}, + {"expired user success uses latest partial probe", now - 901, []service.ProbeHistoryPoint{{At: now - 10, Status: 2}}, false, 2, "detection", now - 10}, + {"healthy probe overrides historical hourly error", 0, []service.ProbeHistoryPoint{{At: now - 100, Status: 1}}, false, 1, "detection", now - 100}, + {"latest probe determines availability", 0, []service.ProbeHistoryPoint{{At: now - 100, Status: 0}, {At: now - 200, Status: 1}}, false, 0, "detection", now - 100}, + {"one missed probe remains fresh", 0, []service.ProbeHistoryPoint{{At: now - 1800, Status: 1}}, false, 1, "detection", now - 1800}, + {"stale probe is unknown", 0, []service.ProbeHistoryPoint{{At: now - 1801, Status: 0}}, false, -1, "detection", 0}, + {"quality failures alone do not imply an outage", 0, nil, false, -1, "detection", 0}, + {"disabled remains disabled", now, []service.ProbeHistoryPoint{{At: now, Status: 1}}, true, 0, "disabled", 0}, + {"future evidence is ignored", now + 1, []service.ProbeHistoryPoint{{At: now + 1, Status: 1}}, false, -1, "detection", 0}, } { t.Run(tc.name, func(t *testing.T) { - m := Monitor{Status: 0, ChannelDisabled: tc.disabled} - applyUserSuccess(&m, tc.success, tc.probe, now) + m := Monitor{Status: 0, StatusSource: "requests", StatusCheckedAt: now - 100, ChannelDisabled: tc.disabled, ProbeHistory: tc.probes, Uptime: 0.58, Fingerprint: &service.AstraSamplingSummary{Errors: 25}, Pelican: &service.PelicanSummary{Failed: 33}} + applyCurrentStatus(&m, perfmetrics.UserRequestResult{At: tc.success * 1000, Success: true}, now) assert.Equal(t, tc.want, m.Status) + assert.Equal(t, tc.source, m.StatusSource) + assert.Equal(t, tc.at, m.StatusCheckedAt) + assert.Equal(t, 25, m.Fingerprint.Errors) + assert.Equal(t, 33, m.Pelican.Failed) + assert.Equal(t, 0.58, m.Uptime) + }) + } +} + +func TestFailedUserRequestChangesGroupStatus(t *testing.T) { + const now int64 = 10000 + for _, tc := range []struct { + name string + at int64 + want int + source string + }{ + {"user failure overrides a newer healthy probe", now - 10, 0, "requests"}, + {"user failure expires after fifteen minutes", now - 901, 1, "detection"}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Monitor{Status: 1, ProbeHistory: []service.ProbeHistoryPoint{{At: now, Status: 1}}} + applyCurrentStatus(&m, perfmetrics.UserRequestResult{At: tc.at * 1000, Success: false}, now) + assert.Equal(t, tc.want, m.Status) + assert.Equal(t, tc.source, m.StatusSource) }) } } diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go index 7f00cf9..de609e5 100644 --- a/controller/uptime_kuma.go +++ b/controller/uptime_kuma.go @@ -126,7 +126,7 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult, groupCacheSnapshot.updated = now groupCacheSnapshot.channels = string(encoded) } - successes := perfmetrics.LatestUserSuccess(ctx, order) + userResults := perfmetrics.LatestUserResults(ctx, order) monitors := make([]Monitor, 0, len(order)) for _, group := range order { monitor := Monitor{ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group]} @@ -158,16 +158,8 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult, monitor.Metrics.Group = monitor.Name } monitor.Pelican = detections[group].Pelican - monitor.StatusSource = "requests" - if monitor.ChannelDisabled { - monitor.StatusSource = "disabled" - } else if monitor.Status < 0 { - monitor.Status = detections[group].Status - monitor.StatusSource = "detection" - monitor.StatusCheckedAt = detections[group].At - } monitor.ProbeHistory = detections[group].ProbeHistory - applyUserSuccess(&monitor, successes[group], detections[group].At, now.Unix()) + applyCurrentStatus(&monitor, userResults[group], now.Unix()) monitors = append(monitors, monitor) } return []UptimeGroupResult{{CategoryName: "Groups", Monitors: monitors}}, nil @@ -196,11 +188,31 @@ func requestStatus(rate float64) int { return 0 } -func applyUserSuccess(monitor *Monitor, success, probe, now int64) { - if !monitor.ChannelDisabled && success > 0 && success >= now-900 && success >= probe { - monitor.Status = 1 +func applyCurrentStatus(monitor *Monitor, result perfmetrics.UserRequestResult, now int64) { + monitor.Status, monitor.StatusCheckedAt = -1, 0 + monitor.StatusSource = "detection" + if monitor.ChannelDisabled { + monitor.Status = 0 + monitor.StatusSource = "disabled" + return + } + // Current availability uses dedicated probes, not quality tests or hourly + // request ratios. Allow one missed 15-minute probe before evidence expires. + for _, probe := range monitor.ProbeHistory { + if probe.At > 0 && probe.At >= now-1800 && probe.At <= now && probe.At >= monitor.StatusCheckedAt { + monitor.Status = probe.Status + monitor.StatusCheckedAt = probe.At + } + } + // The latest real user result is stronger evidence than synthetic checks + // on another member channel, whether it succeeded or failed. + if at := result.At / 1000; at > 0 && at >= now-900 && at <= now { + monitor.Status = 0 + if result.Success { + monitor.Status = 1 + } monitor.StatusSource = "requests" - monitor.StatusCheckedAt = success + monitor.StatusCheckedAt = at } } diff --git a/pkg/perf_metrics/live_status.go b/pkg/perf_metrics/live_status.go index a2e5327..ecbf614 100644 --- a/pkg/perf_metrics/live_status.go +++ b/pkg/perf_metrics/live_status.go @@ -2,59 +2,79 @@ package perfmetrics import ( "context" - "github.com/QuantumNous/new-api/common" "strconv" + "strings" "sync" "time" + + "github.com/QuantumNous/new-api/common" ) -var liveSuccess sync.Map - -type successTime struct { - sync.Mutex - at int64 +// At is milliseconds so a success and failure in the same second stay ordered. +type UserRequestResult struct { + At int64 + Success bool } -func recordUserSuccess(group string, at int64) { +var liveResults sync.Map + +type userResultState struct { + sync.Mutex + result UserRequestResult +} + +func recordUserResult(group string, result UserRequestResult) { if group == "" { return } - value, _ := liveSuccess.LoadOrStore(group, &successTime{}) - v := value.(*successTime) + value, _ := liveResults.LoadOrStore(group, &userResultState{}) + v := value.(*userResultState) v.Lock() - v.at = max(v.at, at) + if result.At >= v.result.At { + v.result = result + } v.Unlock() if common.RedisEnabled && common.RDB != nil { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - // A delayed writer must not replace a newer successful request. - _ = common.RDB.Eval(ctx, `local old=tonumber(redis.call('GET',KEYS[1]) or '0'); if tonumber(ARGV[1])>old then redis.call('SET',KEYS[1],ARGV[1],'EX',900) end; return 1`, []string{"status:user-success:" + group}, at).Err() + encoded := strconv.FormatInt(result.At, 10) + ":" + strconv.FormatBool(result.Success) + // Delayed writers cannot replace newer evidence from another instance. + _ = common.RDB.Eval(ctx, `local old=redis.call('GET',KEYS[1]); local at=old and tonumber(string.match(old,'^(%d+):')) or 0; if tonumber(ARGV[1])>=at then redis.call('SET',KEYS[1],ARGV[2],'EX',900) end; return 1`, []string{"status:user-result:" + group}, result.At, encoded).Err() } } -func LatestUserSuccess(ctx context.Context, groups []string) map[string]int64 { - result := map[string]int64{} +func LatestUserResults(ctx context.Context, groups []string) map[string]UserRequestResult { + result := map[string]UserRequestResult{} for _, group := range groups { - if value, ok := liveSuccess.Load(group); ok { - v := value.(*successTime) + if value, ok := liveResults.Load(group); ok { + v := value.(*userResultState) v.Lock() - result[group] = v.at + result[group] = v.result v.Unlock() } } if common.RedisEnabled && common.RDB != nil && len(groups) > 0 { keys := make([]string, len(groups)) for i, g := range groups { - keys[i] = "status:user-success:" + g + keys[i] = "status:user-result:" + g } ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() values, err := common.RDB.MGet(ctx, keys...).Result() if err == nil { for i, value := range values { - if s, ok := value.(string); ok { - at, _ := strconv.ParseInt(s, 10, 64) - result[groups[i]] = max(result[groups[i]], at) + s, ok := value.(string) + if !ok { + continue + } + atText, successText, ok := strings.Cut(s, ":") + if !ok { + continue + } + at, atErr := strconv.ParseInt(atText, 10, 64) + success, successErr := strconv.ParseBool(successText) + if atErr == nil && successErr == nil && at >= result[groups[i]].At { + result[groups[i]] = UserRequestResult{At: at, Success: success} } } } diff --git a/pkg/perf_metrics/live_status_test.go b/pkg/perf_metrics/live_status_test.go index 60e15c3..4790d77 100644 --- a/pkg/perf_metrics/live_status_test.go +++ b/pkg/perf_metrics/live_status_test.go @@ -2,35 +2,83 @@ package perfmetrics import ( "context" - relaycommon "github.com/QuantumNous/new-api/relay/common" - "github.com/QuantumNous/new-api/relaykit/types" - "github.com/stretchr/testify/assert" "testing" "time" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestLiveUserSuccess(t *testing.T) { - t.Cleanup(func() { - for _, name := range []string{"user", "probe", "internal", "failed"} { - liveSuccess.Delete(name) - } - }) +func TestLiveUserResults(t *testing.T) { + oldRedis := common.RedisEnabled + common.RedisEnabled = false + t.Cleanup(func() { common.RedisEnabled = oldRedis }) + canceled, cancel := context.WithCancel(context.Background()) + cancel() for _, tc := range []struct { - name string - test bool - token int - fail bool - want bool + name string + test bool + token int + ctx context.Context + err *types.NewAPIError + want bool + success bool }{ - {"user", false, 1, false, true}, {"probe", true, 1, false, false}, {"internal", false, 0, false, false}, {"failed", false, 1, true, false}, + {"success", false, 1, context.Background(), nil, true, true}, + {"upstream 503", false, 1, context.Background(), types.InitOpenAIError("server_error", 503), true, false}, + {"probe", true, 1, context.Background(), types.InitOpenAIError("server_error", 503), false, false}, + {"internal", false, 0, context.Background(), nil, false, false}, + {"canceled", false, 1, canceled, types.InitOpenAIError("server_error", 503), false, false}, + {"bad prompt", false, 1, context.Background(), types.InitOpenAIError("context_length_exceeded", 400), false, false}, } { t.Run(tc.name, func(t *testing.T) { - var apiErr *types.NewAPIError - if tc.fail { - apiErr = types.InitOpenAIError("server_error", 500) + t.Cleanup(func() { liveResults.Delete(tc.name) }) + RecordRelayResult(tc.ctx, &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, tc.err) + result := LatestUserResults(context.Background(), []string{tc.name})[tc.name] + assert.Equal(t, tc.want, result.At > 0) + if tc.want { + assert.Equal(t, tc.success, result.Success) } - RecordRelayResult(context.Background(), &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, apiErr) - assert.Equal(t, tc.want, LatestUserSuccess(context.Background(), []string{tc.name})[tc.name] > 0) }) } } + +func TestUserResultsAreOrderedAndSurviveRestart(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + oldRedis, oldClient := common.RedisEnabled, common.RDB + common.RedisEnabled, common.RDB = true, client + t.Cleanup(func() { + common.RedisEnabled, common.RDB = oldRedis, oldClient + liveResults.Delete("group") + liveResults.Delete("other") + client.Close() + }) + ctx := context.Background() + check := func(want UserRequestResult) { + assert.Equal(t, want, LatestUserResults(ctx, []string{"group"})["group"]) + liveResults.Delete("group") + assert.Equal(t, want, LatestUserResults(ctx, []string{"group"})["group"]) + } + recordUserResult("other", UserRequestResult{At: 2000, Success: true}) + recordUserResult("group", UserRequestResult{At: 2000, Success: true}) + check(UserRequestResult{At: 2000, Success: true}) + recordUserResult("group", UserRequestResult{At: 2001, Success: false}) + check(UserRequestResult{At: 2001, Success: false}) + recordUserResult("group", UserRequestResult{At: 2000, Success: true}) + check(UserRequestResult{At: 2001, Success: false}) + recordUserResult("group", UserRequestResult{At: 2002, Success: true}) + recordUserResult("group", UserRequestResult{At: 2001, Success: false}) + check(UserRequestResult{At: 2002, Success: true}) + assert.True(t, LatestUserResults(ctx, []string{"other"})["other"].Success) + ttl, err := client.TTL(ctx, "status:user-result:group").Result() + require.NoError(t, err) + assert.Equal(t, 15*time.Minute, ttl) + server.FastForward(15 * time.Minute) + assert.Zero(t, LatestUserResults(ctx, []string{"group"})["group"].At) +} diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go index 10eea29..2cb3e8d 100644 --- a/pkg/perf_metrics/metrics.go +++ b/pkg/perf_metrics/metrics.go @@ -38,8 +38,8 @@ func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr return } now := time.Now() - if outcome == OutcomeSuccess && !info.IsChannelTest && info.TokenId > 0 { - recordUserSuccess(info.UsingGroup, now.Unix()) + if !info.IsChannelTest && info.TokenId > 0 { + recordUserResult(info.UsingGroup, UserRequestResult{At: now.UnixMilli(), Success: outcome == OutcomeSuccess}) } hasTtft := info.IsStream && info.HasSendResponse() ttftMs := int64(0) diff --git a/web/src/features/group-status/__tests__/status-page.test.tsx b/web/src/features/group-status/__tests__/status-page.test.tsx index 3a42946..f9d6003 100644 --- a/web/src/features/group-status/__tests__/status-page.test.tsx +++ b/web/src/features/group-status/__tests__/status-page.test.tsx @@ -194,9 +194,20 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => { data: [ { categoryName: 'Groups', - monitors: [ - { name: 'Failed Astra', status: 0, uptime: 0, models: ['gpt-6-astra'] }, - { name: 'Luna only', status: 1, uptime: 1, models: ['gpt-6-luna'] }, + monitors: [ + { + name: 'Failed Astra', + status: 0, + uptime: 0, + models: ['gpt-6-astra'], + }, + { name: 'Luna only', status: 1, uptime: 1, models: ['gpt-6-luna'] }, + { + name: 'Degraded Astra', + status: 2, + uptime: 0.8, + models: ['gpt-6-astra'], + }, { name: 'Astra group', status: 1, @@ -214,12 +225,12 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => { screen .getAllByRole('heading', { level: 2 }) .map((heading) => heading.textContent) - ).toEqual(['Astra group', 'Luna only', 'Failed Astra']) - expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(2) - expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(2) + ).toEqual(['Degraded Astra', 'Astra group', 'Luna only', 'Failed Astra']) + expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(3) + expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(3) expect( screen.getAllByRole('region', { name: t('Availability history') }) - ).toHaveLength(3) + ).toHaveLength(4) }) it('keeps the main-site group status link on the original internal route', () => { diff --git a/web/src/features/group-status/__tests__/status.test.ts b/web/src/features/group-status/__tests__/status.test.ts index 4dcf59b..71c016c 100644 --- a/web/src/features/group-status/__tests__/status.test.ts +++ b/web/src/features/group-status/__tests__/status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { cacheHitRates, + compareStatusMonitors, formatCacheHitRate, getOverallStatus, getProbeInterval, @@ -9,6 +10,32 @@ import { } from '../lib/status' describe('group status mapping', () => { + it('prioritizes normal and degraded groups together, then Astra', () => { + const rows = [ + { name: 'down-astra', status: 0, uptime: 0, models: ['gpt-6-astra'] }, + { name: 'normal-luna', status: 1, uptime: 1, models: ['gpt-6-luna'] }, + { + name: 'partial-astra', + status: 2, + uptime: 0.8, + models: ['gpt-6-astra'], + }, + { name: 'normal-astra', status: 1, uptime: 1, models: ['gpt-6-astra'] }, + { name: 'degraded-luna', status: 3, uptime: 0.8, models: ['gpt-6-luna'] }, + { name: 'unknown-luna', status: -1, uptime: 0, models: ['gpt-6-luna'] }, + ] + expect( + [...rows].sort(compareStatusMonitors).map((row) => row.name) + ).toEqual([ + 'partial-astra', + 'normal-astra', + 'normal-luna', + 'degraded-luna', + 'down-astra', + 'unknown-luna', + ]) + }) + it('maps down monitors to error', () => { expect(statusKind(0)).toBe('error') }) diff --git a/web/src/features/group-status/lib/status.ts b/web/src/features/group-status/lib/status.ts index 4c753e5..543ed53 100644 --- a/web/src/features/group-status/lib/status.ts +++ b/web/src/features/group-status/lib/status.ts @@ -6,8 +6,20 @@ export function supportsAstra(monitor: UptimeMonitor): boolean { return monitor.models?.includes('gpt-6-astra') ?? false } -export function compareStatusMonitors(a: UptimeMonitor, b: UptimeMonitor): number { - return Number(b.status === 1) - Number(a.status === 1) || Number(supportsAstra(b)) - Number(supportsAstra(a)) +export function compareStatusMonitors( + a: UptimeMonitor, + b: UptimeMonitor +): number { + const aUsable = + statusKind(a.status) === 'operational' || + statusKind(a.status) === 'degraded' + const bUsable = + statusKind(b.status) === 'operational' || + statusKind(b.status) === 'degraded' + return ( + Number(bUsable) - Number(aUsable) || + Number(supportsAstra(b)) - Number(supportsAstra(a)) + ) } export function statusKind(status: number): GroupStatusKind {