fix: follow live request results and prioritize usable groups
This commit is contained in:
1 parent
5152a4080e
commit
d09216bbde
12 files changed
+257
-86
No files matched your search
+7
-3
@@ -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 优先。
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+26
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user