15 changed files with 370 additions and 103 deletions

No files matched your search

+7 -3
View File
@@ -1,6 +1,6 @@
# 自定义版本说明
当前版本为 v1.0.0-rc.38-custom.42-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.42-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 优先。
+2 -2
View File
@@ -1,3 +1,3 @@
FROM local/new-api:v1.0.0-rc.38-custom.41-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.42-20260926"
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
+1 -1
View File
@@ -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.42-20260926"
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
+1 -1
View File
@@ -1 +1 @@
v1.0.0-rc.38-custom.42-20260926
v1.0.0-rc.38-custom.44-20260926
+50 -13
View File
@@ -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
View File
@@ -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
}
}
+41 -21
View File
@@ -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}
}
}
}
+68 -20
View File
@@ -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)
}
+2 -2
View File
@@ -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)
@@ -13,9 +13,75 @@ import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { FingerprintRetest } from '../fingerprint-panel'
import { MonitorCard } from '../index'
import { GroupStatus, MonitorCard } from '../index'
vi.mock('@/components/layout', () => ({ PublicLayout: () => null }))
vi.mock('@/components/layout', () => ({
PublicLayout: ({ children }: { children: React.ReactNode }) => children,
}))
it('uses the shared rows while preserving main-site names, artworks and metrics', async () => {
const get = vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: [
{
categoryName: 'Groups',
monitors: [
{
name: '#alias',
group: 'real-astra',
status: 1,
uptime: 1,
models: ['gpt-6-astra'],
metrics: {
avg_ttft_ms: 10730,
avg_latency_ms: 22250,
avg_tps: 26.6,
success_rate: 100,
},
pelican: {
channels: 1,
normal: 1,
degraded: 0,
unknown: 0,
failed: 0,
running: 0,
history: [
{
id: 'hidden-uptime-work',
at: 1,
status: 'succeeded',
quality: 'normal',
user_initiated: false,
has_html: true,
},
],
},
},
],
},
],
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<GroupStatus />
</QueryClientProvider>
)
await screen.findByRole('heading', { name: 'real-astra' })
expect(get).toHaveBeenCalledWith('/api/group-status')
expect(
screen.queryByRole('heading', { name: '#alias' })
).not.toBeInTheDocument()
expect(
screen.getByTitle(t('Pelican sample {{number}}', { number: 1 }))
).toHaveAttribute('src', '/api/group-status/artwork/hidden-uptime-work')
fireEvent.click(screen.getByRole('button', { name: t('Detection details') }))
expect(await screen.findByText('10.7 s')).toBeInTheDocument()
})
afterEach(() => {
cleanup()
@@ -195,7 +195,19 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => {
{
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'] },
{
name: 'Degraded Astra',
status: 2,
uptime: 0.8,
models: ['gpt-6-astra'],
},
{
name: 'Astra group',
status: 1,
@@ -213,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'])
expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(1)
expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(1)
).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(2)
).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')
})
+16 -8
View File
@@ -27,12 +27,14 @@ import { DetectionActions, PelicanPanel } from './detection-panel'
import { FingerprintPanel } from './fingerprint-panel'
import {
cacheHitRates,
compareStatusMonitors,
formatCacheHitRate,
getOverallStatus,
statusKind,
type GroupStatusKind,
} from './lib/status'
import { ProbeHistory } from './probe-history'
import { StatusGroupRow } from './status-page'
import { ResetGroupDetections } from './status-page-settings'
type StatusKind = GroupStatusKind
@@ -191,9 +193,9 @@ export function GroupStatus() {
})
const monitors = useMemo<UptimeMonitor[]>(
() =>
(query.data?.data || []).flatMap(
(group: UptimeGroupResult) => group.monitors || []
),
(query.data?.data || [])
.flatMap((group: UptimeGroupResult) => group.monitors || [])
.sort(compareStatusMonitors),
[query.data?.data]
)
const overall = getOverallStatus(monitors.map((monitor) => monitor.status))
@@ -205,7 +207,7 @@ export function GroupStatus() {
)
if (query.isLoading) {
content = (
<div className='mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
<div className='mt-7 space-y-5'>
{[0, 1, 2].map((item) => (
<Skeleton key={item} className='h-[400px] rounded-lg' />
))}
@@ -219,13 +221,19 @@ export function GroupStatus() {
)
} else if (monitors.length) {
content = (
<div className='mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
<div className='mt-7 space-y-5'>
{monitors.map((monitor) => (
<MonitorCard
<StatusGroupRow
key={`${monitor.group}-${monitor.name}`}
monitor={monitor}
monitor={{ ...monitor, name: monitor.group || monitor.name }}
updatedAt={query.dataUpdatedAt}
refreshing={query.isFetching}
artworkBase='/api/group-status/artwork/'
actions={
!!monitor.fingerprint?.channels &&
!!monitor.group && <DetectionActions group={monitor.group} />
}
detailsContent={<MonitorCard monitor={monitor} />}
/>
))}
</div>
@@ -234,7 +242,7 @@ export function GroupStatus() {
return (
<PublicLayout showMainContainer={false}>
<main className='mx-auto w-full max-w-7xl min-w-0 px-4 pt-24 pb-12 md:px-6'>
<main className='mx-auto w-full max-w-[1680px] min-w-0 px-5 pt-24 pb-12 md:px-8'>
<div className='flex flex-wrap items-end justify-between gap-4'>
<div>
<h1 className='text-3xl font-bold'>{t('Group Status')}</h1>
@@ -2,6 +2,26 @@ import type { UptimeMonitor, UptimeSample } from '@/features/dashboard/types'
export type GroupStatusKind = 'operational' | 'degraded' | 'error' | 'unknown'
export function supportsAstra(monitor: UptimeMonitor): boolean {
return monitor.models?.includes('gpt-6-astra') ?? false
}
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 {
if (status < 0) return 'unknown'
if (status === 1) return 'operational'
+25 -12
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { Activity, ArrowUpRight, RefreshCw, Settings2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
@@ -19,7 +19,12 @@ import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { FingerprintPanel } from './fingerprint-panel'
import { getOverallStatus, statusKind } from './lib/status'
import {
getOverallStatus,
statusKind,
supportsAstra,
compareStatusMonitors,
} from './lib/status'
import { ProbeHistory } from './probe-history'
import { getStatusPage } from './status-page-api'
import {
@@ -41,11 +46,11 @@ function qualityLabel(quality: string): string {
return 'Unable to classify'
}
function supportsAstra(monitor: UptimeMonitor): boolean {
return monitor.models?.includes('gpt-6-astra') ?? false
}
function ArtworkThumbnail(props: { id: string; title: string }) {
function ArtworkThumbnail(props: {
id: string
title: string
artworkBase: string
}) {
const container = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(0)
useEffect(() => {
@@ -64,7 +69,7 @@ function ArtworkThumbnail(props: { id: string; title: string }) {
>
<iframe
title={props.title}
src={'/api/detections/artwork/' + props.id}
src={props.artworkBase + props.id}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
loading='lazy'
@@ -102,10 +107,14 @@ export function StatusGroupRow(props: {
monitor: UptimeMonitor
updatedAt?: number
refreshing?: boolean
artworkBase?: string
actions?: ReactNode
detailsContent?: ReactNode
}) {
const { t, i18n } = useTranslation()
const monitor = props.monitor
const astra = supportsAstra(monitor)
const artworkBase = props.artworkBase || '/api/detections/artwork/'
const [selected, setSelected] = useState('')
const [details, setDetails] = useState(false)
const user = useAuthStore((state) => state.auth.user)
@@ -166,7 +175,7 @@ export function StatusGroupRow(props: {
</p>
</div>
)}
{astra && (
{(astra || props.detailsContent) && (
<Button
variant='ghost'
size='sm'
@@ -177,6 +186,7 @@ export function StatusGroupRow(props: {
<ArrowUpRight className='size-3.5' />
</Button>
)}
{props.actions}
</CardHeader>
{astra && (
<CardContent className='min-w-0 p-5'>
@@ -208,6 +218,7 @@ export function StatusGroupRow(props: {
>
<ArtworkThumbnail
id={work.id}
artworkBase={artworkBase}
title={t('Pelican sample {{number}}', {
number: index + 1,
})}
@@ -316,7 +327,7 @@ export function StatusGroupRow(props: {
<iframe
title={t('Pelican work preview')}
tabIndex={-1}
src={`/api/detections/artwork/${selectedWork.id}`}
src={artworkBase + selectedWork.id}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
className='h-[65vh] w-full rounded-lg border bg-white'
@@ -328,7 +339,9 @@ export function StatusGroupRow(props: {
onOpenChange={setDetails}
title={`${monitor.name} · ${t('Detection details')}`}
>
<FingerprintPanel data={monitor.fingerprint} />
{props.detailsContent || (
<FingerprintPanel data={monitor.fingerprint} />
)}
</Dialog>
</Card>
)
@@ -345,7 +358,7 @@ export function GroupStatus() {
refetchInterval: 15_000,
})
const monitors = (query.data?.flatMap((group) => group.monitors) || []).sort(
(a, b) => Number(supportsAstra(b)) - Number(supportsAstra(a))
compareStatusMonitors
)
const overall = getOverallStatus(monitors.map((monitor) => monitor.status))
const operational = monitors.filter((monitor) => monitor.status === 1).length