Commit 53771922 by Calcium-Ion Committed by GitHub

feat: add system task runner (#5680)

parent acb52d0f
......@@ -158,10 +158,21 @@ var RetryTimes = 0
var IsMasterNode bool
// NodeName 节点名称,从 NODE_NAME 环境变量读取;
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
const (
NodeNameSourceManual = "manual"
NodeNameSourceHostname = "hostname"
)
// NodeName 节点名称,优先从 NODE_NAME 环境变量读取,未配置时回退主机名。
// 用于审计日志和后台任务中标识节点身份;多实例部署时建议显式配置稳定 NODE_NAME。
var NodeName = ""
// NodeNameSource records how NodeName was chosen so future instance-management
// reporting can distinguish operator-configured names from automatic fallback.
var NodeNameSource = NodeNameSourceHostname
var NodeNameManuallyConfigured bool
var requestInterval int
var RequestInterval time.Duration
......
......@@ -82,9 +82,7 @@ func InitEnv() {
DebugEnabled = os.Getenv("DEBUG") == "true"
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
// NodeName 优先用 NODE_NAME,未配置时回退主机名(容器下=容器 ID/Pod 名,自动扩容天然唯一)。
hostname, _ := os.Hostname()
NodeName = GetEnvOrDefaultString("NODE_NAME", hostname)
initNodeNameIdentity()
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
if TLSInsecureSkipVerify {
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
......
package common
import "os"
type NodeIdentity struct {
Name string `json:"name"`
Source string `json:"source"`
ManuallyConfigured bool `json:"manually_configured"`
ShouldConfigureManually bool `json:"should_configure_manually"`
}
func initNodeNameIdentity() {
if envNodeName := os.Getenv("NODE_NAME"); envNodeName != "" {
NodeName = envNodeName
NodeNameSource = NodeNameSourceManual
NodeNameManuallyConfigured = true
return
}
hostname, _ := os.Hostname()
NodeName = hostname
NodeNameSource = NodeNameSourceHostname
NodeNameManuallyConfigured = false
}
func GetNodeIdentity() NodeIdentity {
return NodeIdentity{
Name: NodeName,
Source: NodeNameSource,
ManuallyConfigured: NodeNameManuallyConfigured,
ShouldConfigureManually: !NodeNameManuallyConfigured,
}
}
......@@ -2,6 +2,7 @@ package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
......@@ -9,10 +10,8 @@ import (
"math"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
......@@ -30,7 +29,6 @@ import (
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
"github.com/tidwall/gjson"
......@@ -74,7 +72,10 @@ func resolveChannelTestUserID(c *gin.Context) (int, error) {
return rootUser.Id, nil
}
func testChannel(channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
func testChannel(ctx context.Context, channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
if ctx == nil {
ctx = context.Background()
}
tik := time.Now()
var unsupportedTestChannelTypes = []int{
constant.ChannelTypeMidjourney,
......@@ -153,12 +154,7 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo
testModel = ratio_setting.WithCompactModelSuffix(testModel)
}
c.Request = &http.Request{
Method: "POST",
URL: &url.URL{Path: requestPath}, // 使用动态路径
Body: nil,
Header: make(http.Header),
}
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, requestPath, nil)
cache, err := model.GetUserCache(testUserID)
if err != nil {
......@@ -857,7 +853,11 @@ func TestChannel(c *gin.Context) {
return
}
tik := time.Now()
result := testChannel(channel, testUserID, testModel, endpointType, isStream)
requestCtx := context.Background()
if c.Request != nil {
requestCtx = c.Request.Context()
}
result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream)
if result.localErr != nil {
resp := gin.H{
"success": false,
......@@ -890,74 +890,129 @@ func TestChannel(c *gin.Context) {
})
}
var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false
// channelTestSummary records the outcome of one channel test cycle so the
// system task can persist a per-run result for history.
type channelTestSummary struct {
Tested int `json:"tested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Disabled int `json:"disabled"`
Enabled int `json:"enabled"`
}
func testChannels(channels []*model.Channel, testUserID int, notify bool, allowDisable bool) error {
testAllChannelsLock.Lock()
if testAllChannelsRunning {
testAllChannelsLock.Unlock()
return errors.New("测试已在运行中")
}
testAllChannelsRunning = true
testAllChannelsLock.Unlock()
// performChannelTests runs the channel test loop synchronously, honoring ctx
// cancellation so a system-task runner that loses its lease stops promptly. When
// report is non-nil it is called after each channel with (processed, total) so
// the system task can surface progress.
func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, report func(processed, total int)) channelTestSummary {
summary := channelTestSummary{}
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
if disableThreshold == 0 {
disableThreshold = 10000000 // a impossible value
}
gopool.Go(func() {
// 使用 defer 确保无论如何都会重置运行状态,防止死锁
defer func() {
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
}()
for _, channel := range channels {
if channel.Status == common.ChannelStatusManuallyDisabled {
continue
}
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
shouldBanChannel := false
newAPIError := result.newAPIError
// request error disables the channel
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
}
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
}
total := len(channels)
for index, channel := range channels {
if ctx != nil && ctx.Err() != nil {
break
}
if report != nil {
report(index, total) // channels completed before this one
}
if channel.Status == common.ChannelStatusManuallyDisabled {
continue
}
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if ctx != nil && ctx.Err() != nil {
break
}
// disable channel
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
}
summary.Tested++
shouldBanChannel := false
newAPIError := result.newAPIError
// request error disables the channel
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
}
// enable channel
if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
}
channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval)
if newAPIError == nil {
summary.Succeeded++
} else {
summary.Failed++
}
if notify {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
// disable channel
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
summary.Disabled++
}
})
return nil
// enable channel
if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
summary.Enabled++
}
channel.UpdateResponseTime(milliseconds)
if common.RequestInterval > 0 {
if ctx == nil {
time.Sleep(common.RequestInterval)
} else {
select {
case <-ctx.Done():
return summary
case <-time.After(common.RequestInterval):
}
}
}
}
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(total, total) // mark complete only when the full set was tested
}
return summary
}
// runChannelTestTask runs one synchronous channel test cycle for the system task
// runner (both the scheduled job and the manual "test all channels" trigger go
// through here). It honors ctx cancellation so a runner that loses its lease
// stops promptly. mode selects the channel set: an empty mode falls back to the
// configured monitor ChannelTestMode (scheduled behavior), while a manual
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
// is set the root user is notified on completion. Cross-instance execution is
// guarded by the system task per-type lock, so no process-local guard is needed.
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
testUserID, err := resolveChannelTestUserID(nil)
if err != nil {
return channelTestSummary{}, err
}
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
return channelTestSummary{}, err
}
if strings.TrimSpace(mode) == "" {
mode = operation_setting.GetMonitorSetting().ChannelTestMode
}
selected := selectChannelsForAutomaticTest(channels, mode)
allowDisable := mode != operation_setting.ChannelTestModePassiveRecovery
summary := performChannelTests(ctx, selected, testUserID, allowDisable, report)
if notify && (ctx == nil || ctx.Err() == nil) {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
}
return summary, nil
}
func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*model.Channel {
......@@ -974,71 +1029,36 @@ func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*m
return selected
}
func testAllChannels(notify bool) error {
testUserID, err := resolveChannelTestUserID(nil)
if err != nil {
return err
}
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
if getChannelErr != nil {
return getChannelErr
}
return testChannels(selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeScheduledAll), testUserID, notify, true)
}
func testAutoDisabledChannels(notify bool) error {
testUserID, err := resolveChannelTestUserID(nil)
if err != nil {
return err
}
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
if getChannelErr != nil {
return getChannelErr
}
return testChannels(selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModePassiveRecovery), testUserID, notify, false)
}
// TestAllChannels enqueues a channel_test system task instead of running the
// test loop inline. If any channel_test task is already active, the manual run is
// rejected so the caller does not mistake a scheduled run for this manual one.
func TestAllChannels(c *gin.Context) {
err := testAllChannels(true)
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeChannelTest, channelTestTaskPayload{
Mode: operation_setting.ChannelTestModeScheduledAll,
Notify: true,
})
if err != nil {
common.ApiError(c, err)
return
}
if !created {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
// 只在Master节点定时测试渠道
if !common.IsMasterNode {
return
}
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(1 * time.Minute)
continue
}
for {
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
if operation_setting.GetMonitorSetting().ChannelTestMode == operation_setting.ChannelTestModePassiveRecovery {
common.SysLog("automatically testing auto-disabled channels")
_ = testAutoDisabledChannels(false)
} else {
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
}
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
},
})
}
package controller
import (
"net/http"
"net/http/httptest"
"testing"
......@@ -109,3 +110,21 @@ func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T
require.Equal(t, 1, selected[0].Id)
require.Equal(t, 2, selected[1].Id)
}
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
existing, err := model.CreateSystemTask(model.SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/test", nil)
TestAllChannels(ctx)
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中")
}
package controller
import (
"context"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/QuantumNous/new-api/common"
......@@ -52,16 +52,12 @@ var channelUpstreamModelUpdateSelectFields = []string{
"header_override",
}
var (
channelUpstreamModelUpdateTaskOnce sync.Once
channelUpstreamModelUpdateTaskRunning atomic.Bool
channelUpstreamModelUpdateNotifyState = struct {
sync.Mutex
lastNotifiedAt int64
lastChangedChannels int
lastFailedChannels int
}{}
)
var channelUpstreamModelUpdateNotifyState = struct {
sync.Mutex
lastNotifiedAt int64
lastChangedChannels int
lastFailedChannels int
}{}
type applyChannelUpstreamModelUpdatesRequest struct {
ID int `json:"id"`
......@@ -519,12 +515,24 @@ func buildUpstreamModelUpdateTaskNotificationContent(
return builder.String()
}
func runChannelUpstreamModelUpdateTaskOnce() {
if !channelUpstreamModelUpdateTaskRunning.CompareAndSwap(false, true) {
return
}
defer channelUpstreamModelUpdateTaskRunning.Store(false)
type upstreamModelUpdateSummary struct {
CheckedChannels int `json:"checked_channels"`
ChangedChannels int `json:"changed_channels"`
DetectedAddModels int `json:"detected_add_models"`
DetectedRemoveModels int `json:"detected_remove_models"`
FailedChannels int `json:"failed_channels"`
AutoAddedModels int `json:"auto_added_models"`
}
// runChannelUpstreamModelUpdateTaskOnce runs one synchronous upstream model
// detection cycle and returns a summary for system task history. It honors ctx
// cancellation between batches so a runner that loses its lease stops promptly.
// force bypasses the per-channel minimum check interval and allowAutoApply lets
// channels with auto-sync enabled adopt detected models automatically. The
// scheduled job calls (force=false, allowAutoApply=true); the manual "detect
// all" trigger calls (force=true, allowAutoApply=false) so it always re-checks
// and only stages changes for explicit review.
func runChannelUpstreamModelUpdateTaskOnce(ctx context.Context, force bool, allowAutoApply bool, report func(processed, total int)) upstreamModelUpdateSummary {
checkedChannels := 0
failedChannels := 0
failedChannelIDs := make([]int, 0)
......@@ -537,8 +545,20 @@ func runChannelUpstreamModelUpdateTaskOnce() {
removeModelSamples := make([]string, 0)
refreshNeeded := false
// Count the enabled channels up front so progress can be reported as a
// percentage; a count error is non-fatal (progress just won't show a %).
var totalChannels int64
if err := model.DB.Model(&model.Channel{}).Where("status = ?", common.ChannelStatusEnabled).Count(&totalChannels).Error; err != nil {
totalChannels = 0
}
processed := 0
lastID := 0
scanLoop:
for {
if ctx != nil && ctx.Err() != nil {
break
}
var channels []*model.Channel
query := model.DB.
Select(channelUpstreamModelUpdateSelectFields).
......@@ -562,6 +582,14 @@ func runChannelUpstreamModelUpdateTaskOnce() {
if channel == nil {
continue
}
if ctx != nil && ctx.Err() != nil {
break scanLoop
}
processed++
if report != nil {
report(processed, int(totalChannels))
}
settings := channel.GetOtherSettings()
if !settings.UpstreamModelUpdateCheckEnabled {
......@@ -569,7 +597,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
}
checkedChannels++
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, false, true)
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, force, allowAutoApply)
if err != nil {
failedChannels++
failedChannelIDs = append(failedChannelIDs, channel.Id)
......@@ -598,7 +626,15 @@ func runChannelUpstreamModelUpdateTaskOnce() {
autoAddedModels += autoAdded
if common.RequestInterval > 0 {
time.Sleep(common.RequestInterval)
if ctx == nil {
time.Sleep(common.RequestInterval)
} else {
select {
case <-ctx.Done():
break scanLoop
case <-time.After(common.RequestInterval):
}
}
}
}
......@@ -607,10 +643,23 @@ func runChannelUpstreamModelUpdateTaskOnce() {
}
}
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(int(totalChannels), int(totalChannels)) // mark complete only when the full scan finished
}
if refreshNeeded {
refreshChannelRuntimeCache()
}
summary := upstreamModelUpdateSummary{
CheckedChannels: checkedChannels,
ChangedChannels: changedChannels,
DetectedAddModels: detectedAddModels,
DetectedRemoveModels: detectedRemoveModels,
FailedChannels: failedChannels,
AutoAddedModels: autoAddedModels,
}
if checkedChannels > 0 || common.DebugEnabled {
common.SysLog(fmt.Sprintf(
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
......@@ -630,7 +679,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
changedChannels,
failedChannels,
))
return
return summary
}
service.NotifyUpstreamModelUpdateWatchers(
"上游模型巡检通知",
......@@ -647,37 +696,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
),
)
}
}
func StartChannelUpstreamModelUpdateTask() {
channelUpstreamModelUpdateTaskOnce.Do(func() {
if !common.IsMasterNode {
return
}
if !common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true) {
common.SysLog("upstream model update task disabled by CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED")
return
}
intervalMinutes := common.GetEnvOrDefault(
"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
)
if intervalMinutes < 1 {
intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
}
interval := time.Duration(intervalMinutes) * time.Minute
go func() {
common.SysLog(fmt.Sprintf("upstream model update task started: interval=%s", interval))
runChannelUpstreamModelUpdateTaskOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
runChannelUpstreamModelUpdateTaskOnce()
}
}()
})
return summary
}
func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
......@@ -931,75 +950,40 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
})
}
// DetectAllChannelUpstreamModelUpdates enqueues a model_update system task
// (manual variant) instead of scanning inline. Routing the manual trigger
// through the framework gives it the same cross-instance lease dedup and run
// history as the scheduled scan. If any model_update task is already active, the
// manual run is rejected so the caller does not mistake a scheduled run for this
// manual one.
func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
results := make([]detectChannelUpstreamModelUpdatesResult, 0)
failed := make([]int, 0)
detectedAddCount := 0
detectedRemoveCount := 0
refreshNeeded := false
lastID := 0
for {
channels, err := findEnabledChannelsAfterID(lastID, channelUpstreamModelUpdateTaskBatchSize)
if err != nil {
common.ApiError(c, err)
return
}
if len(channels) == 0 {
break
}
lastID = channels[len(channels)-1].Id
for _, channel := range channels {
if channel == nil {
continue
}
settings := channel.GetOtherSettings()
if !settings.UpstreamModelUpdateCheckEnabled {
continue
}
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
if err != nil {
failed = append(failed, channel.Id)
continue
}
if modelsChanged {
refreshNeeded = true
}
addModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
removeModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
detectedAddCount += len(addModels)
detectedRemoveCount += len(removeModels)
results = append(results, detectChannelUpstreamModelUpdatesResult{
ChannelID: channel.Id,
ChannelName: channel.Name,
AddModels: addModels,
RemoveModels: removeModels,
LastCheckTime: settings.UpstreamModelUpdateLastCheckTime,
AutoAddedModels: autoAdded,
})
}
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
break
}
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true})
if err != nil {
common.ApiError(c, err)
return
}
if refreshNeeded {
refreshChannelRuntimeCache()
if !created {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
},
})
return
}
recordManageAudit(c, "channel.upstream_detect_all", map[string]interface{}{
"task_id": task.TaskID,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"processed_channels": len(results),
"failed_channel_ids": failed,
"detected_add_models": detectedAddCount,
"detected_remove_models": detectedRemoveCount,
"channel_detected_results": results,
"task_id": task.TaskID,
"status": task.Status,
},
})
}
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
......@@ -177,3 +180,21 @@ func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90001, 0, 0))
}
func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
existing, err := model.CreateSystemTask(model.SystemTaskTypeModelUpdate, nil, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/upstream-models/detect-all", nil)
DetectAllChannelUpstreamModelUpdates(ctx)
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
}
......@@ -3,7 +3,6 @@ package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
......@@ -20,183 +19,223 @@ import (
"github.com/gin-gonic/gin"
)
func UpdateMidjourneyTaskBulk() {
//imageModel := "midjourney"
ctx := context.TODO()
for {
time.Sleep(time.Duration(15) * time.Second)
// midjourneyPollSummary is the result recorded on a midjourney_poll system task
// row, summarizing one polling pass.
type midjourneyPollSummary struct {
UnfinishedTasks int `json:"unfinished_tasks"`
ChannelsScanned int `json:"channels_scanned"`
NullTasksFailed int `json:"null_tasks_failed"`
}
// runMidjourneyTaskUpdateOnce performs one Midjourney polling pass synchronously.
// It honors ctx cancellation (the system-task runner cancels it when the lease
// is lost) and, when report is non-nil, reports progress as (processedChannels,
// totalChannels) so the system task surfaces a percentage.
func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, total int)) midjourneyPollSummary {
summary := midjourneyPollSummary{}
if ctx == nil {
ctx = context.Background()
}
tasks := model.GetAllUnFinishTasks()
if len(tasks) == 0 {
return summary
}
summary.UnfinishedTasks = len(tasks)
tasks := model.GetAllUnFinishTasks()
if len(tasks) == 0 {
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
for _, task := range tasks {
if task.MjId == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.Id)
continue
}
taskM[task.MjId] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
}
if len(nullTaskIds) > 0 {
summary.NullTasksFailed = len(nullTaskIds)
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
return summary
}
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
for _, task := range tasks {
if task.MjId == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.Id)
continue
}
taskM[task.MjId] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
totalChannels := len(taskChannelM)
processedChannels := 0
for channelId, taskIds := range taskChannelM {
if ctx != nil && ctx.Err() != nil {
break
}
if report != nil {
report(processedChannels, totalChannels)
}
processedChannels++
summary.ChannelsScanned++
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
}
if len(nullTaskIds) > 0 {
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
err := model.MjBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
}
continue
}
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
body, err := common.Marshal(map[string]any{
"ids": taskIds,
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
continue
}
timeout := time.Second * 15
requestCtx, cancel := context.WithTimeout(ctx, timeout)
req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
cancel()
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
cancel()
continue
}
if resp.StatusCode != http.StatusOK {
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
resp.Body.Close()
cancel()
continue
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
resp.Body.Close()
cancel()
continue
}
if len(taskChannelM) == 0 {
var responseItems []dto.MidjourneyDto
err = common.Unmarshal(responseBody, &responseItems)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
resp.Body.Close()
cancel()
continue
}
resp.Body.Close()
req.Body.Close()
cancel()
for channelId, taskIds := range taskChannelM {
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
for _, responseItem := range responseItems {
task := taskM[responseItem.MjId]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
continue
}
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
err := model.MjBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
}
continue
}
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
body, _ := json.Marshal(map[string]any{
"ids": taskIds,
})
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
continue
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
// 如果时间超过一小时,且进度不是100%,则认为任务失败
if useTime > 3600000 && task.Progress != "100%" {
responseItem.FailReason = "上游任务超时(超过1小时)"
responseItem.Status = "FAILURE"
}
// 设置超时时间
timeout := time.Second * 15
ctx, cancel := context.WithTimeout(context.Background(), timeout)
// 使用带有超时的 context 创建新的请求
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
if !checkMjTaskNeedUpdate(task, responseItem) {
continue
}
if resp.StatusCode != http.StatusOK {
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
continue
preStatus := task.Status
task.Code = 1
task.Progress = responseItem.Progress
task.PromptEn = responseItem.PromptEn
task.State = responseItem.State
task.SubmitTime = responseItem.SubmitTime
task.StartTime = responseItem.StartTime
task.FinishTime = responseItem.FinishTime
task.ImageUrl = responseItem.ImageUrl
task.Status = responseItem.Status
task.FailReason = responseItem.FailReason
if responseItem.Properties != nil {
propertiesStr, _ := common.Marshal(responseItem.Properties)
task.Properties = string(propertiesStr)
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
continue
if responseItem.Buttons != nil {
buttonStr, _ := common.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr)
}
var responseItems []dto.MidjourneyDto
err = json.Unmarshal(responseBody, &responseItems)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
continue
}
resp.Body.Close()
req.Body.Close()
cancel()
for _, responseItem := range responseItems {
task := taskM[responseItem.MjId]
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
// 如果时间超过一小时,且进度不是100%,则认为任务失败
if useTime > 3600000 && task.Progress != "100%" {
responseItem.FailReason = "上游任务超时(超过1小时)"
responseItem.Status = "FAILURE"
}
if !checkMjTaskNeedUpdate(task, responseItem) {
continue
}
preStatus := task.Status
task.Code = 1
task.Progress = responseItem.Progress
task.PromptEn = responseItem.PromptEn
task.State = responseItem.State
task.SubmitTime = responseItem.SubmitTime
task.StartTime = responseItem.StartTime
task.FinishTime = responseItem.FinishTime
task.ImageUrl = responseItem.ImageUrl
task.Status = responseItem.Status
task.FailReason = responseItem.FailReason
if responseItem.Properties != nil {
propertiesStr, _ := json.Marshal(responseItem.Properties)
task.Properties = string(propertiesStr)
}
if responseItem.Buttons != nil {
buttonStr, _ := json.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr)
}
// 映射 VideoUrl
task.VideoUrl = responseItem.VideoUrl
// 映射 VideoUrl
task.VideoUrl = responseItem.VideoUrl
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = string(videoUrlsStr)
}
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = "" // 空值时清空字段
task.VideoUrls = string(videoUrlsStr)
}
} else {
task.VideoUrls = "" // 空值时清空字段
}
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
}
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
}
won, err := task.UpdateWithStatus(preStatus)
}
won, err := task.UpdateWithStatus(preStatus)
if err != nil {
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else if won && shouldReturnQuota {
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
if err != nil {
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else if won && shouldReturnQuota {
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
if err != nil {
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: task.ChannelId,
ModelName: service.CovertMjpActionToModelName(task.Action),
Quota: task.Quota,
Other: map[string]interface{}{
"task_id": task.MjId,
"reason": "构图失败",
},
})
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: task.ChannelId,
ModelName: service.CovertMjpActionToModelName(task.Action),
Quota: task.Quota,
Other: map[string]interface{}{
"task_id": task.MjId,
"reason": "构图失败",
},
})
}
}
}
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(totalChannels, totalChannels)
}
return summary
}
func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool {
......@@ -242,7 +281,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
}
// 检查 VideoUrls 是否需要更新
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
newVideoUrlsStr, _ := common.Marshal(newTask.VideoUrls)
if oldTask.VideoUrls != string(newVideoUrlsStr) {
return true
}
......
......@@ -65,6 +65,27 @@ func GetCurrentSystemTask(c *gin.Context) {
})
}
func ListSystemTasks(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
tasks, err := model.ListSystemTasks(limit)
if err != nil {
common.ApiError(c, err)
return
}
responses := make([]model.SystemTaskResponse, 0, len(tasks))
for _, task := range tasks {
responses = append(responses, task.ToResponse())
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responses,
})
}
func GetSystemTask(c *gin.Context) {
taskID := c.Param("task_id")
if taskID == "" {
......
package controller
import (
"context"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
// RegisterScheduledSystemTasks wires the periodic channel test, upstream model
// update, and async task polling (Midjourney / Suno / video) jobs into the
// system task framework so a DB lease dedups execution across multiple master
// instances and each run is recorded as one task row. Call this before
// service.StartSystemTaskRunner.
func RegisterScheduledSystemTasks() {
service.RegisterSystemTaskHandler(channelTestHandler{})
service.RegisterSystemTaskHandler(modelUpdateHandler{})
service.RegisterSystemTaskHandler(midjourneyPollHandler{})
service.RegisterSystemTaskHandler(asyncTaskPollHandler{})
}
// channelTestHandler runs the scheduled "test all channels" job. Enablement and
// cadence still come from the monitor settings; only the execution path moved
// into the system task runner.
type channelTestHandler struct{}
func (channelTestHandler) Type() string { return model.SystemTaskTypeChannelTest }
func (channelTestHandler) Enabled() bool {
return operation_setting.GetMonitorSetting().AutoTestChannelEnabled
}
func (channelTestHandler) Interval() time.Duration {
minutes := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
if minutes <= 0 {
minutes = 10
}
return time.Duration(minutes * float64(time.Minute))
}
func (channelTestHandler) NewPayload() any { return nil }
// channelTestTaskPayload controls one channel_test run. A nil/empty payload is a
// scheduled run, which uses the configured monitor ChannelTestMode and does not
// notify. A manual "test all channels" trigger sets Mode=scheduled_all and
// Notify=true to reproduce the legacy manual behavior (test every channel and
// notify root on completion).
type channelTestTaskPayload struct {
Mode string `json:"mode,omitempty"`
Notify bool `json:"notify,omitempty"`
}
func (channelTestHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := channelTestTaskPayload{}
if err := task.DecodePayload(&payload); err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
summary, err := runChannelTestTask(ctx, payload.Mode, payload.Notify, service.NewSystemTaskProgressReporter(task, runnerID))
if err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// modelUpdateHandler runs the scheduled upstream model update detection job.
type modelUpdateHandler struct{}
func (modelUpdateHandler) Type() string { return model.SystemTaskTypeModelUpdate }
func (modelUpdateHandler) Enabled() bool {
return common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true)
}
func (modelUpdateHandler) Interval() time.Duration {
intervalMinutes := common.GetEnvOrDefault(
"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
)
if intervalMinutes < 1 {
intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
}
return time.Duration(intervalMinutes) * time.Minute
}
func (modelUpdateHandler) NewPayload() any { return nil }
// modelUpdateTaskPayload controls one model_update run. A scheduled run
// (Manual=false) respects the per-channel minimum check interval and may
// auto-apply detected models when a channel has auto-sync enabled. A manual
// "detect all" trigger sets Manual=true to reproduce the legacy detect-all
// semantics: force a re-check regardless of the interval and never auto-apply,
// so the admin reviews and applies changes explicitly.
type modelUpdateTaskPayload struct {
Manual bool `json:"manual,omitempty"`
}
func (modelUpdateHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := modelUpdateTaskPayload{}
if err := task.DecodePayload(&payload); err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
summary := runChannelUpstreamModelUpdateTaskOnce(ctx, payload.Manual, !payload.Manual, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// midjourneyPollHandler runs one Midjourney polling pass per scheduled run.
// Enabled() folds the "are there unfinished tasks?" check into enablement so the
// scheduler creates no row when the system is idle; only when at least one
// Midjourney task is in progress does a row get scheduled.
type midjourneyPollHandler struct{}
func (midjourneyPollHandler) Type() string { return model.SystemTaskTypeMidjourneyPoll }
func (midjourneyPollHandler) Enabled() bool {
return constant.UpdateTask && model.HasUnfinishedMidjourneyTasks()
}
func (midjourneyPollHandler) Interval() time.Duration { return 15 * time.Second }
func (midjourneyPollHandler) NewPayload() any { return nil }
func (midjourneyPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
summary := runMidjourneyTaskUpdateOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// asyncTaskPollHandler runs one async-task (Suno/video) polling pass per
// scheduled run. Like midjourneyPollHandler, Enabled() folds in the unfinished
// task existence check so an idle system schedules no rows.
type asyncTaskPollHandler struct{}
func (asyncTaskPollHandler) Type() string { return model.SystemTaskTypeAsyncTaskPoll }
func (asyncTaskPollHandler) Enabled() bool {
return constant.UpdateTask && model.HasUnfinishedSyncTasks()
}
func (asyncTaskPollHandler) Interval() time.Duration { return 15 * time.Second }
func (asyncTaskPollHandler) NewPayload() any { return nil }
func (asyncTaskPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
summary := service.RunTaskPollingOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
func finishSystemTaskHandler(task *model.SystemTask, runnerID string, status model.SystemTaskStatus, result any, runErr error) {
errorMessage := ""
if runErr != nil {
errorMessage = runErr.Error()
}
if err := model.FinishSystemTask(task.TaskID, runnerID, status, result, errorMessage); err != nil {
common.SysLog(fmt.Sprintf("system task %s failed to persist result: %v", task.TaskID, err))
}
}
......@@ -8,17 +8,11 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// UpdateTaskBulk 薄入口,实际轮询逻辑在 service 层
func UpdateTaskBulk() {
service.TaskPollingLoop()
}
func GetAllTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
......
......@@ -111,18 +111,15 @@ func main() {
go controller.AutomaticallyUpdateChannels(frequency)
}
go controller.AutomaticallyTestChannels()
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
service.StartCodexCredentialAutoRefreshTask()
// Subscription quota reset task (daily/weekly/monthly/custom)
service.StartSubscriptionQuotaResetTask()
// Persistent system maintenance task runner
service.StartSystemTaskRunner()
// Wire task polling adaptor factory (breaks service -> relay import cycle)
// Wire task polling adaptor factory (breaks service -> relay import cycle).
// Must run before the system task runner starts: the async_task_poll handler
// calls service.RunTaskPollingOnce, which needs this factory set.
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
a := relay.GetTaskAdaptor(platform)
if a == nil {
......@@ -131,17 +128,14 @@ func main() {
return a
}
// Channel upstream model update check task
controller.StartChannelUpstreamModelUpdateTask()
// Register the periodic channel test, upstream model update, and async task
// polling (Midjourney / Suno / video) jobs as scheduled system tasks
// (DB-lease dedup across masters + run history), then start the runner that
// schedules and executes them. Master-only execution and the UpdateTask
// switch are enforced inside the runner and each handler's Enabled().
controller.RegisterScheduledSystemTasks()
service.StartSystemTaskRunner()
if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk()
})
gopool.Go(func() {
controller.UpdateTaskBulk()
})
}
if os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
common.BatchUpdateEnabled = true
common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
......
......@@ -295,6 +295,7 @@ func migrateDB() error {
&UserOAuthBinding{},
&PerfMetric{},
&SystemTask{},
&SystemTaskLock{},
)
if err != nil {
return err
......@@ -345,6 +346,7 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&PerfMetric{}, "PerfMetric"},
{&SystemTask{}, "SystemTask"},
{&SystemTaskLock{}, "SystemTaskLock"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
......
......@@ -101,6 +101,19 @@ func GetAllUnFinishTasks() []*Midjourney {
return tasks
}
// HasUnfinishedMidjourneyTasks reports whether at least one Midjourney task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the midjourney_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedMidjourneyTasks() bool {
var id int
err := DB.Model(&Midjourney{}).
Where("progress != ?", "100%").
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyMJId(mjId string) *Midjourney {
var mj *Midjourney
var err error
......
......@@ -16,41 +16,51 @@ const (
SystemTaskStatusSucceeded SystemTaskStatus = "succeeded"
SystemTaskStatusFailed SystemTaskStatus = "failed"
SystemTaskTypeLogCleanup = "log_cleanup"
SystemTaskTypeLogCleanup = "log_cleanup"
SystemTaskTypeChannelTest = "channel_test"
SystemTaskTypeModelUpdate = "model_update"
SystemTaskTypeMidjourneyPoll = "midjourney_poll"
SystemTaskTypeAsyncTaskPoll = "async_task_poll"
)
var ErrSystemTaskLockLost = errors.New("system task lock lost")
type SystemTask struct {
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
TaskID string `json:"task_id" gorm:"type:varchar(64);uniqueIndex"`
Type string `json:"type" gorm:"type:varchar(64);index"`
Status SystemTaskStatus `json:"status" gorm:"type:varchar(32);index"`
ActiveKey *string `json:"active_key,omitempty" gorm:"type:varchar(64);uniqueIndex"`
Payload string `json:"payload" gorm:"type:text"`
State string `json:"state" gorm:"type:text"`
Result string `json:"result" gorm:"type:text"`
Error string `json:"error" gorm:"type:text"`
LockedBy string `json:"locked_by" gorm:"type:varchar(128);index"`
LockedUntil int64 `json:"locked_until" gorm:"bigint;index"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
ID int64 `json:"id" gorm:"primary_key"`
TaskID string `json:"task_id" gorm:"type:varchar(64);uniqueIndex"`
Type string `json:"type" gorm:"type:varchar(64);index"`
Status SystemTaskStatus `json:"status" gorm:"type:varchar(32);index"`
ActiveKey *string `json:"active_key,omitempty" gorm:"type:varchar(64);uniqueIndex"`
Payload string `json:"payload" gorm:"type:text"`
State string `json:"state" gorm:"type:text"`
Result string `json:"result" gorm:"type:text"`
Error string `json:"error" gorm:"type:text"`
LockedBy string `json:"locked_by" gorm:"type:varchar(128);index"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
}
type SystemTaskLock struct {
Type string `json:"type" gorm:"type:varchar(64);primaryKey"`
TaskID string `json:"task_id" gorm:"type:varchar(64);index"`
LockedBy string `json:"locked_by" gorm:"type:varchar(128);index"`
LockedUntil int64 `json:"locked_until" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
}
type SystemTaskResponse struct {
ID int64 `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
Status SystemTaskStatus `json:"status"`
ActiveKey string `json:"active_key,omitempty"`
Payload any `json:"payload"`
State any `json:"state"`
Result any `json:"result"`
Error string `json:"error"`
LockedBy string `json:"locked_by"`
LockedUntil int64 `json:"locked_until"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID int64 `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
Status SystemTaskStatus `json:"status"`
ActiveKey *string `json:"active_key,omitempty"`
Payload any `json:"payload"`
State any `json:"state"`
Result any `json:"result"`
Error string `json:"error"`
LockedBy string `json:"locked_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func (task *SystemTask) BeforeCreate(_ *gorm.DB) error {
......@@ -64,6 +74,13 @@ func (task *SystemTask) BeforeCreate(_ *gorm.DB) error {
return nil
}
func (lock *SystemTaskLock) BeforeCreate(_ *gorm.DB) error {
if lock.UpdatedAt == 0 {
lock.UpdatedAt = common.GetTimestamp()
}
return nil
}
func GenerateSystemTaskID() (string, error) {
key, err := common.GenerateRandomCharsKey(32)
if err != nil {
......@@ -72,7 +89,7 @@ func GenerateSystemTaskID() (string, error) {
return "systask_" + key, nil
}
func CreateSystemTask(taskType string, activeKey string, payload any, state any) (*SystemTask, error) {
func CreateSystemTask(taskType string, payload any, state any) (*SystemTask, error) {
taskID, err := GenerateSystemTaskID()
if err != nil {
return nil, err
......@@ -87,14 +104,12 @@ func CreateSystemTask(taskType string, activeKey string, payload any, state any)
}
task := &SystemTask{
TaskID: taskID,
Type: taskType,
Status: SystemTaskStatusPending,
Payload: payloadText,
State: stateText,
}
if activeKey != "" {
task.ActiveKey = &activeKey
TaskID: taskID,
Type: taskType,
Status: SystemTaskStatusPending,
ActiveKey: &taskType,
Payload: payloadText,
State: stateText,
}
if err := DB.Create(task).Error; err != nil {
......@@ -116,8 +131,7 @@ func GetSystemTaskByTaskID(taskID string) (*SystemTask, error) {
func GetActiveSystemTask(taskType string) (*SystemTask, error) {
var task SystemTask
err := DB.Where("type = ? AND active_key IS NOT NULL", taskType).
Where("status IN ?", activeSystemTaskStatuses()).
err := DB.Where("type = ? AND status IN ?", taskType, activeSystemTaskStatuses()).
Order("id desc").
First(&task).Error
if err != nil {
......@@ -129,53 +143,198 @@ func GetActiveSystemTask(taskType string) (*SystemTask, error) {
return &task, nil
}
func FindRunnableSystemTasks(taskType string, now int64, limit int) ([]*SystemTask, error) {
func FindPendingSystemTasks(taskType string, limit int) ([]*SystemTask, error) {
var tasks []*SystemTask
if limit <= 0 {
limit = 1
}
err := DB.Where("type = ? AND status IN ? AND (locked_until = 0 OR locked_until < ?)", taskType, activeSystemTaskStatuses(), now).
err := DB.Where("type = ? AND status = ?", taskType, SystemTaskStatusPending).
Order("id asc").
Limit(limit).
Find(&tasks).Error
return tasks, err
}
func FindEarliestPendingSystemTasks(taskTypes []string) (map[string]*SystemTask, error) {
tasksByType := map[string]*SystemTask{}
if len(taskTypes) == 0 {
return tasksByType, nil
}
subQuery := DB.Model(&SystemTask{}).
Select("MIN(id)").
Where("type IN ? AND status = ?", taskTypes, SystemTaskStatusPending).
Group("type")
var tasks []*SystemTask
if err := DB.Where("id IN (?)", subQuery).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
tasksByType[task.Type] = task
}
return tasksByType, nil
}
func ListSystemTasks(limit int) ([]*SystemTask, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var tasks []*SystemTask
err := DB.Order("id desc").Limit(limit).Find(&tasks).Error
return tasks, err
}
// GetLatestSystemTask returns the most recent task row of the given type
// (any status) so the scheduler can decide whether enough time has elapsed
// since the last run. Returns (nil, nil) when no row exists.
func GetLatestSystemTask(taskType string) (*SystemTask, error) {
var task SystemTask
err := DB.Where("type = ?", taskType).Order("id desc").First(&task).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &task, nil
}
func GetLatestSystemTasks(taskTypes []string) (map[string]*SystemTask, error) {
tasksByType := map[string]*SystemTask{}
if len(taskTypes) == 0 {
return tasksByType, nil
}
subQuery := DB.Model(&SystemTask{}).
Select("MAX(id)").
Where("type IN ?", taskTypes).
Group("type")
var tasks []*SystemTask
if err := DB.Where("id IN (?)", subQuery).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
tasksByType[task.Type] = task
}
return tasksByType, nil
}
func ClaimSystemTask(id int64, taskType string, runnerID string, lockUntil int64) (*SystemTask, bool, error) {
now := common.GetTimestamp()
var task SystemTask
if err := DB.Where("id = ? AND type = ? AND status = ?", id, taskType, SystemTaskStatusPending).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
return nil, false, err
}
acquired, expiredTaskID, err := acquireSystemTaskLock(taskType, task.TaskID, runnerID, now, lockUntil)
if err != nil || !acquired {
return nil, acquired, err
}
if expiredTaskID != "" && expiredTaskID != task.TaskID {
if err := MarkSystemTaskLeaseExpired(expiredTaskID); err != nil {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, err
}
}
result := DB.Model(&SystemTask{}).
Where("id = ? AND type = ? AND status IN ? AND (locked_until = 0 OR locked_until < ? OR locked_by = ?)", id, taskType, activeSystemTaskStatuses(), now, runnerID).
Where("id = ? AND type = ? AND status = ?", id, taskType, SystemTaskStatusPending).
Updates(map[string]any{
"status": SystemTaskStatusRunning,
"locked_by": runnerID,
"locked_until": lockUntil,
"updated_at": now,
"status": SystemTaskStatusRunning,
"locked_by": runnerID,
"updated_at": now,
})
if result.Error != nil {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, result.Error
}
if result.RowsAffected == 0 {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, nil
}
var task SystemTask
if err := DB.Where("id = ?", id).First(&task).Error; err != nil {
return nil, false, err
}
return &task, true, nil
}
func UpdateSystemTaskState(taskID string, lockedBy string, state any, lockUntil int64) error {
func acquireSystemTaskLock(taskType string, taskID string, lockedBy string, now int64, lockUntil int64) (bool, string, error) {
lock := &SystemTaskLock{
Type: taskType,
TaskID: taskID,
LockedBy: lockedBy,
LockedUntil: lockUntil,
UpdatedAt: now,
}
if err := DB.Create(lock).Error; err == nil {
return true, "", nil
}
var existing SystemTaskLock
err := DB.Where("type = ?", taskType).First(&existing).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, "", nil
}
return false, "", err
}
if existing.LockedUntil >= now {
return false, "", nil
}
result := DB.Model(&SystemTaskLock{}).
Where("type = ? AND locked_until < ?", taskType, now).
Updates(map[string]any{
"task_id": taskID,
"locked_by": lockedBy,
"locked_until": lockUntil,
"updated_at": now,
})
if result.Error != nil {
return false, "", result.Error
}
if result.RowsAffected == 0 {
return false, "", nil
}
return true, existing.TaskID, nil
}
func UpdateSystemTaskState(taskID string, lockedBy string, state any) error {
stateText, err := marshalSystemTaskJSON(state)
if err != nil {
return err
}
now := common.GetTimestamp()
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
Updates(map[string]any{
"state": stateText,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrSystemTaskLockLost
}
return nil
}
func RenewSystemTaskLock(taskID string, lockedBy string, lockUntil int64) error {
now := common.GetTimestamp()
result := DB.Model(&SystemTaskLock{}).
Where("task_id = ? AND locked_by = ? AND locked_until >= ?", taskID, lockedBy, now).
Updates(map[string]any{
"state": stateText,
"locked_until": lockUntil,
"updated_at": common.GetTimestamp(),
"updated_at": now,
})
if result.Error != nil {
return result.Error
......@@ -186,21 +345,56 @@ func UpdateSystemTaskState(taskID string, lockedBy string, state any, lockUntil
return nil
}
func MarkSystemTaskLeaseExpired(taskID string) error {
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ?", taskID, SystemTaskStatusRunning).
Updates(map[string]any{
"status": SystemTaskStatusFailed,
"active_key": nil,
"error": "task lease expired",
"updated_at": common.GetTimestamp(),
})
return result.Error
}
func ExpireStaleSystemTaskLocks(now int64) error {
var locks []*SystemTaskLock
if err := DB.Where("locked_until < ?", now).Find(&locks).Error; err != nil {
return err
}
for _, lock := range locks {
if err := MarkSystemTaskLeaseExpired(lock.TaskID); err != nil {
return err
}
result := DB.Where("type = ? AND task_id = ? AND locked_by = ? AND locked_until < ?", lock.Type, lock.TaskID, lock.LockedBy, now).
Delete(&SystemTaskLock{})
if result.Error != nil {
return result.Error
}
}
return nil
}
func ReleaseSystemTaskLock(taskID string, lockedBy string) error {
result := DB.Where("task_id = ? AND locked_by = ?", taskID, lockedBy).Delete(&SystemTaskLock{})
return result.Error
}
func FinishSystemTask(taskID string, lockedBy string, status SystemTaskStatus, resultPayload any, errorMessage string) error {
resultText, err := marshalSystemTaskJSON(resultPayload)
if err != nil {
return err
}
now := common.GetTimestamp()
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
Updates(map[string]any{
"status": status,
"active_key": nil,
"result": resultText,
"error": errorMessage,
"locked_by": "",
"locked_until": 0,
"updated_at": common.GetTimestamp(),
"status": status,
"active_key": nil,
"result": resultText,
"error": errorMessage,
"updated_at": now,
})
if result.Error != nil {
return result.Error
......@@ -208,7 +402,7 @@ func FinishSystemTask(taskID string, lockedBy string, status SystemTaskStatus, r
if result.RowsAffected == 0 {
return ErrSystemTaskLockLost
}
return nil
return ReleaseSystemTaskLock(taskID, lockedBy)
}
func (task *SystemTask) DecodePayload(v any) error {
......@@ -220,24 +414,19 @@ func (task *SystemTask) DecodeState(v any) error {
}
func (task *SystemTask) ToResponse() SystemTaskResponse {
activeKey := ""
if task.ActiveKey != nil {
activeKey = *task.ActiveKey
}
return SystemTaskResponse{
ID: task.ID,
TaskID: task.TaskID,
Type: task.Type,
Status: task.Status,
ActiveKey: activeKey,
Payload: decodeSystemTaskJSONValue(task.Payload),
State: decodeSystemTaskJSONValue(task.State),
Result: decodeSystemTaskJSONValue(task.Result),
Error: task.Error,
LockedBy: task.LockedBy,
LockedUntil: task.LockedUntil,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
ID: task.ID,
TaskID: task.TaskID,
Type: task.Type,
Status: task.Status,
ActiveKey: task.ActiveKey,
Payload: decodeSystemTaskJSONValue(task.Payload),
State: decodeSystemTaskJSONValue(task.State),
Result: decodeSystemTaskJSONValue(task.Result),
Error: task.Error,
LockedBy: task.LockedBy,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
}
......
......@@ -21,21 +21,33 @@ type testSystemTaskState struct {
Remaining int64 `json:"remaining"`
}
func TestSystemTaskActiveKeyIsReleasedOnFinish(t *testing.T) {
func createLegacyPendingSystemTask(t *testing.T, taskType string) *SystemTask {
t.Helper()
taskID, err := GenerateSystemTaskID()
require.NoError(t, err)
task := &SystemTask{
TaskID: taskID,
Type: taskType,
Status: SystemTaskStatusPending,
}
require.NoError(t, DB.Create(task).Error)
return task
}
func TestSystemTaskCreateAndActiveLifecycle(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
state := testSystemTaskState{}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, SystemTaskTypeLogCleanup, payload, state)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, state)
require.NoError(t, err)
require.NotNil(t, task.ActiveKey)
assert.Equal(t, SystemTaskTypeLogCleanup, *task.ActiveKey)
var decodedPayload testSystemTaskPayload
require.NoError(t, task.DecodePayload(&decodedPayload))
assert.Equal(t, payload, decodedPayload)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, SystemTaskTypeLogCleanup, payload, state)
require.Error(t, err)
activeTask, err := GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.NotNil(t, activeTask)
......@@ -49,35 +61,292 @@ func TestSystemTaskActiveKeyIsReleasedOnFinish(t *testing.T) {
err = FinishSystemTask(claimedTask.TaskID, runnerID, SystemTaskStatusSucceeded, map[string]int64{"deleted_count": 0}, "")
require.NoError(t, err)
finishedTask, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, finishedTask)
assert.Nil(t, finishedTask.ActiveKey)
activeTask, err = GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.Nil(t, activeTask)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, SystemTaskTypeLogCleanup, payload, state)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, payload, state)
require.NoError(t, err)
}
func TestSystemTaskClaimRequiresExpiredLock(t *testing.T) {
func TestSystemTaskActiveKeyPreventsDuplicateActiveRun(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.NoError(t, err)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.Error(t, err)
activeTask, err := GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.NotNil(t, activeTask)
assert.Equal(t, task.TaskID, activeTask.TaskID)
}
func TestSystemTaskLockPreventsConcurrentClaim(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.NoError(t, err)
secondTask := createLegacyPendingSystemTask(t, SystemTaskTypeLogCleanup)
claimedTask, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
_, claimed, err = ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
_, claimed, err = ClaimSystemTask(secondTask.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.False(t, claimed)
require.NoError(t, DB.Model(claimedTask).Updates(map[string]any{
"locked_until": common.GetTimestamp() - 1,
}).Error)
assert.Equal(t, "runner-a", claimedTask.LockedBy)
claimedTask, claimed, err = ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
reloadedSecond, err := GetSystemTaskByTaskID(secondTask.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedSecond)
assert.Equal(t, SystemTaskStatusPending, reloadedSecond.Status)
}
func TestExpiredSystemTaskLockFailsOldRunAndClaimsLegacyPendingRun(t *testing.T) {
truncateTables(t)
first, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", first.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
second := createLegacyPendingSystemTask(t, SystemTaskTypeLogCleanup)
claimedTask, claimed, err := ClaimSystemTask(second.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
assert.Equal(t, second.TaskID, claimedTask.TaskID)
assert.Equal(t, "runner-b", claimedTask.LockedBy)
reloadedFirst, err := GetSystemTaskByTaskID(first.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedFirst)
assert.Equal(t, SystemTaskStatusFailed, reloadedFirst.Status)
assert.Equal(t, "task lease expired", reloadedFirst.Error)
assert.Nil(t, reloadedFirst.ActiveKey)
}
func TestExpireStaleSystemTaskLockFailsOldRunAndAllowsNewRun(t *testing.T) {
truncateTables(t)
first, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", first.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
require.NoError(t, ExpireStaleSystemTaskLocks(common.GetTimestamp()))
reloadedFirst, err := GetSystemTaskByTaskID(first.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedFirst)
assert.Equal(t, SystemTaskStatusFailed, reloadedFirst.Status)
assert.Equal(t, "task lease expired", reloadedFirst.Error)
assert.Nil(t, reloadedFirst.ActiveKey)
var lockCount int64
require.NoError(t, DB.Model(&SystemTaskLock{}).Where("task_id = ?", first.TaskID).Count(&lockCount).Error)
assert.Equal(t, int64(0), lockCount)
second, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
require.NotEqual(t, first.TaskID, second.TaskID)
}
func TestFindEarliestPendingSystemTasks(t *testing.T) {
truncateTables(t)
empty, err := FindEarliestPendingSystemTasks(nil)
require.NoError(t, err)
assert.Empty(t, empty)
firstA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
ignoredB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(ignoredB.ID, "type_b", "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(ignoredB.TaskID, "runner-b", SystemTaskStatusFailed, nil, "failed"))
firstB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
ignoredC, err := CreateSystemTask("type_c", nil, nil)
require.NoError(t, err)
_, claimed, err = ClaimSystemTask(ignoredC.ID, "type_c", "runner-c", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(ignoredC.TaskID, "runner-c", SystemTaskStatusFailed, nil, "failed"))
tasks, err := FindEarliestPendingSystemTasks([]string{"type_a", "type_b", "type_c", "missing"})
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.Equal(t, firstA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, firstB.TaskID, tasks["type_b"].TaskID)
assert.Nil(t, tasks["type_c"])
assert.Nil(t, tasks["missing"])
}
func TestGetLatestSystemTask(t *testing.T) {
truncateTables(t)
latest, err := GetLatestSystemTask(SystemTaskTypeChannelTest)
require.NoError(t, err)
require.Nil(t, latest)
first, err := CreateSystemTask(SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeChannelTest, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(first.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
second, err := CreateSystemTask(SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
latest, err = GetLatestSystemTask(SystemTaskTypeChannelTest)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, second.TaskID, latest.TaskID)
}
func TestGetLatestSystemTasks(t *testing.T) {
truncateTables(t)
empty, err := GetLatestSystemTasks(nil)
require.NoError(t, err)
assert.Empty(t, empty)
firstA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
firstB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(firstA.ID, "type_a", "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(firstA.TaskID, "runner-a", SystemTaskStatusSucceeded, nil, ""))
secondA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
tasks, err := GetLatestSystemTasks([]string{"type_a", "type_b", "missing"})
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.NotEqual(t, firstA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, secondA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, firstB.TaskID, tasks["type_b"].TaskID)
assert.Nil(t, tasks["missing"])
}
func TestRenewSystemTaskLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
newLockUntil := common.GetTimestamp() + 600
require.NoError(t, RenewSystemTaskLock(task.TaskID, runnerID, newLockUntil))
var lock SystemTaskLock
require.NoError(t, DB.Where("task_id = ?", task.TaskID).First(&lock).Error)
assert.Equal(t, newLockUntil, lock.LockedUntil)
// A different runner cannot renew a lease it does not hold.
assert.ErrorIs(t, RenewSystemTaskLock(task.TaskID, "runner-b", common.GetTimestamp()+600), ErrSystemTaskLockLost)
// After the task finishes it is no longer running, so renew fails.
require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
assert.ErrorIs(t, RenewSystemTaskLock(task.TaskID, runnerID, common.GetTimestamp()+600), ErrSystemTaskLockLost)
}
func TestFinishSystemTaskRetainsExecutor(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "node-1-abc123"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
reloaded, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, reloaded)
assert.Equal(t, SystemTaskStatusSucceeded, reloaded.Status)
assert.Equal(t, runnerID, reloaded.LockedBy, "executor-of-record must be retained for history")
var lockCount int64
require.NoError(t, DB.Model(&SystemTaskLock{}).Where("task_id = ?", task.TaskID).Count(&lockCount).Error)
assert.Equal(t, int64(0), lockCount)
}
func TestSystemTaskUpdatesRequireCurrentLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", task.TaskID).
Updates(map[string]any{"locked_by": "runner-b"}).Error)
assert.ErrorIs(t, UpdateSystemTaskState(task.TaskID, runnerID, testSystemTaskState{Progress: 10}), ErrSystemTaskLockLost)
assert.ErrorIs(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""), ErrSystemTaskLockLost)
}
func TestSystemTaskUpdatesRequireUnexpiredLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", task.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
assert.ErrorIs(t, UpdateSystemTaskState(task.TaskID, runnerID, testSystemTaskState{Progress: 10}), ErrSystemTaskLockLost)
assert.ErrorIs(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""), ErrSystemTaskLockLost)
reloaded, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, reloaded)
assert.Equal(t, SystemTaskStatusRunning, reloaded.Status)
assert.Empty(t, reloaded.State)
}
......@@ -314,6 +314,21 @@ func GetAllUnFinishSyncTasks(limit int) []*Task {
return tasks
}
// HasUnfinishedSyncTasks reports whether at least one async (Suno/video) task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the async_task_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedSyncTasks() bool {
var id int64
err := DB.Model(&Task{}).
Where("progress != ?", "100%").
Where("status != ?", TaskStatusFailure).
Where("status != ?", TaskStatusSuccess).
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
......
......@@ -49,6 +49,7 @@ func TestMain(m *testing.M) {
&UserOAuthBinding{},
&PerfMetric{},
&SystemTask{},
&SystemTaskLock{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
......@@ -72,6 +73,7 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM user_subscriptions")
DB.Exec("DELETE FROM user_oauth_bindings")
DB.Exec("DELETE FROM perf_metrics")
DB.Exec("DELETE FROM system_task_locks")
DB.Exec("DELETE FROM system_tasks")
})
}
......
......@@ -318,6 +318,7 @@ func SetApiRouter(router *gin.Engine) {
systemTaskRoute.Use(middleware.RootAuth())
{
systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask)
systemTaskRoute.GET("/list", controller.ListSystemTasks)
systemTaskRoute.GET("/current", controller.GetCurrentSystemTask)
systemTaskRoute.GET("/:task_id", controller.GetSystemTask)
}
......
......@@ -15,11 +15,78 @@ import (
)
const (
systemTaskRunnerTickInterval = time.Second
// systemTaskRunnerIdleInterval is the fallback poll interval used to pick up
// tasks created on other nodes and mark expired leases failed.
systemTaskRunnerIdleInterval = 15 * time.Second
systemTaskLockTTL = 60 * time.Second
logCleanupBatchSize = 100
// systemTaskSchedulerInterval throttles how often the scheduler/stale-lock
// pass runs, independent of how often the runner wakes to claim tasks.
systemTaskSchedulerInterval = 15 * time.Second
systemTaskStaleLockInterval = 30 * time.Second
)
// SystemTaskHandler executes a claimed task of a specific type. Run owns the
// task lifecycle from claim to terminal state: it MUST call
// model.FinishSystemTask (succeeded/failed) before returning and MUST honor
// ctx cancellation, which the runner triggers if the per-type lock is lost.
type SystemTaskHandler interface {
Type() string
Run(ctx context.Context, task *model.SystemTask, runnerID string)
}
// ScheduledSystemTaskHandler is a SystemTaskHandler that the scheduler also
// creates periodically when enabled and the configured interval has elapsed
// since the last run.
type ScheduledSystemTaskHandler interface {
SystemTaskHandler
Enabled() bool
Interval() time.Duration
NewPayload() any
}
var (
systemTaskHandlersMu sync.RWMutex
systemTaskHandlers = map[string]SystemTaskHandler{}
)
// RegisterSystemTaskHandler registers a handler keyed by its Type(). It must be
// called before StartSystemTaskRunner (or any time, since the runner snapshots
// the registry every pass). Re-registering a type replaces the previous handler.
func RegisterSystemTaskHandler(h SystemTaskHandler) {
if h == nil {
return
}
systemTaskHandlersMu.Lock()
defer systemTaskHandlersMu.Unlock()
systemTaskHandlers[h.Type()] = h
}
func registeredSystemTaskHandlers() []SystemTaskHandler {
systemTaskHandlersMu.RLock()
defer systemTaskHandlersMu.RUnlock()
handlers := make([]SystemTaskHandler, 0, len(systemTaskHandlers))
for _, h := range systemTaskHandlers {
handlers = append(handlers, h)
}
return handlers
}
// logCleanupHandler wraps the existing on-demand log cleanup task as a
// registered (non-scheduled) handler. It is created via StartLogCleanupTask.
type logCleanupHandler struct{}
func (logCleanupHandler) Type() string { return model.SystemTaskTypeLogCleanup }
func (logCleanupHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
runLogCleanupTask(ctx, task, runnerID)
}
func init() {
RegisterSystemTaskHandler(logCleanupHandler{})
}
type LogCleanupPayload struct {
TargetTimestamp int64 `json:"target_timestamp"`
BatchSize int `json:"batch_size"`
......@@ -36,7 +103,22 @@ type LogCleanupResult struct {
DeletedCount int64 `json:"deleted_count"`
}
var systemTaskRunnerOnce sync.Once
var (
systemTaskRunnerOnce sync.Once
// systemTaskWakeup signals the runner to check for runnable tasks
// immediately instead of waiting for the idle poll. Buffered so a signal
// raised while the runner is busy is not lost and is handled on the next loop.
systemTaskWakeup = make(chan struct{}, 1)
)
// notifySystemTaskRunner wakes the runner without blocking. If a wakeup is
// already pending it is a no-op, which is fine since one pass drains all work.
func notifySystemTaskRunner() {
select {
case systemTaskWakeup <- struct{}{}:
default:
}
}
func StartSystemTaskRunner() {
systemTaskRunnerOnce.Do(func() {
......@@ -46,14 +128,38 @@ func StartSystemTaskRunner() {
runnerID := fmt.Sprintf("%s-%s", common.NodeName, common.GetRandomString(8))
gopool.Go(func() {
logger.LogInfo(context.Background(), fmt.Sprintf("system task runner started: runner=%s tick=%s", runnerID, systemTaskRunnerTickInterval))
logger.LogInfo(context.Background(), fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval))
ticker := time.NewTicker(systemTaskRunnerTickInterval)
ticker := time.NewTicker(systemTaskRunnerIdleInterval)
defer ticker.Stop()
runSystemTaskRunnerOnce(runnerID)
for range ticker.C {
runSystemTaskRunnerOnce(runnerID)
var lastScheduler time.Time
var lastStaleLockCleanup time.Time
runPass := func() {
// The scheduler/stale-lock pass is throttled independently of the
// claim pass: wakeups (e.g. a manual log cleanup) should claim
// immediately without re-running the scheduler every time.
now := time.Now()
if now.Sub(lastStaleLockCleanup) >= systemTaskStaleLockInterval {
lastStaleLockCleanup = now
if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task stale lock cleanup failed: %v", err))
}
}
if now.Sub(lastScheduler) >= systemTaskSchedulerInterval {
lastScheduler = now
runSystemTaskScheduler()
}
runSystemTaskClaimPass(runnerID)
}
runPass()
for {
select {
case <-ticker.C:
case <-systemTaskWakeup:
}
runPass()
}
})
})
......@@ -77,7 +183,7 @@ func StartLogCleanupTask(targetTimestamp int64) (*model.SystemTask, error) {
BatchSize: logCleanupBatchSize,
}
state := LogCleanupState{}
task, err := model.CreateSystemTask(model.SystemTaskTypeLogCleanup, model.SystemTaskTypeLogCleanup, payload, state)
task, err := model.CreateSystemTask(model.SystemTaskTypeLogCleanup, payload, state)
if err != nil {
activeTask, activeErr := model.GetActiveSystemTask(model.SystemTaskTypeLogCleanup)
if activeErr == nil && activeTask != nil {
......@@ -85,19 +191,54 @@ func StartLogCleanupTask(targetTimestamp int64) (*model.SystemTask, error) {
}
return nil, err
}
notifySystemTaskRunner()
return task, nil
}
func runSystemTaskRunnerOnce(runnerID string) {
now := common.GetTimestamp()
tasks, err := model.FindRunnableSystemTasks(model.SystemTaskTypeLogCleanup, now, 1)
// EnqueueSystemTask creates an on-demand task of the given type. The returned
// bool is true only when a new pending row was created; false means an active
// task of the same type already exists and was returned.
func EnqueueSystemTask(taskType string, payload any) (*model.SystemTask, bool, error) {
activeTask, err := model.GetActiveSystemTask(taskType)
if err != nil {
return nil, false, err
}
if activeTask != nil {
return activeTask, false, nil
}
task, err := model.CreateSystemTask(taskType, payload, nil)
if err != nil {
activeTask, activeErr := model.GetActiveSystemTask(taskType)
if activeErr == nil && activeTask != nil {
return activeTask, false, nil
}
return nil, false, err
}
notifySystemTaskRunner()
return task, true, nil
}
// runSystemTaskClaimPass tries to claim one pending task per registered type
// and dispatches each claimed task in its own goroutine so a long-running
// handler (e.g. channel test) never blocks another type (e.g. log cleanup).
func runSystemTaskClaimPass(runnerID string) {
handlers := registeredSystemTaskHandlers()
taskTypes := make([]string, 0, len(handlers))
for _, handler := range handlers {
taskTypes = append(taskTypes, handler.Type())
}
pendingTasks, err := model.FindEarliestPendingSystemTasks(taskTypes)
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task runner query failed: %v", err))
return
}
for _, task := range tasks {
claimedTask, claimed, err := model.ClaimSystemTask(task.ID, model.SystemTaskTypeLogCleanup, runnerID, systemTaskLockUntil())
for _, handler := range handlers {
task := pendingTasks[handler.Type()]
if task == nil {
continue
}
claimedTask, claimed, err := model.ClaimSystemTask(task.ID, handler.Type(), runnerID, systemTaskLockUntil())
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task claim failed: %v", err))
continue
......@@ -105,8 +246,93 @@ func runSystemTaskRunnerOnce(runnerID string) {
if !claimed {
continue
}
runLogCleanupTask(context.Background(), claimedTask, runnerID)
dispatchHandler := handler
dispatchTask := claimedTask
gopool.Go(func() {
runWithLeaseHeartbeat(dispatchTask, runnerID, func(ctx context.Context) {
dispatchHandler.Run(ctx, dispatchTask, runnerID)
})
})
}
}
// runSystemTaskScheduler creates a new task row for each enabled scheduled
// handler whose interval has elapsed since its last run and that has no active
// row. The task active_key unique index deduplicates concurrent creation while
// the per-type lock guarantees only one runner executes the task.
func runSystemTaskScheduler() {
now := common.GetTimestamp()
handlers := registeredSystemTaskHandlers()
scheduledHandlers := make([]ScheduledSystemTaskHandler, 0, len(handlers))
taskTypes := make([]string, 0, len(handlers))
for _, handler := range handlers {
scheduled, ok := handler.(ScheduledSystemTaskHandler)
if !ok || !scheduled.Enabled() {
continue
}
scheduledHandlers = append(scheduledHandlers, scheduled)
taskTypes = append(taskTypes, scheduled.Type())
}
latestTasks, err := model.GetLatestSystemTasks(taskTypes)
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler query failed: %v", err))
return
}
for _, scheduled := range scheduledHandlers {
latest := latestTasks[scheduled.Type()]
if latest != nil {
if latest.Status == model.SystemTaskStatusPending || latest.Status == model.SystemTaskStatusRunning {
continue // an active row already exists
}
if now-latest.UpdatedAt < int64(scheduled.Interval().Seconds()) {
continue // not due yet
}
}
if _, err := model.CreateSystemTask(scheduled.Type(), scheduled.NewPayload(), nil); err != nil {
activeTask, activeErr := model.GetActiveSystemTask(scheduled.Type())
if activeErr == nil && activeTask != nil {
continue
}
if activeErr != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler active lookup failed: type=%s err=%v", scheduled.Type(), activeErr))
}
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler create failed: type=%s err=%v", scheduled.Type(), err))
continue
}
}
}
// runWithLeaseHeartbeat renews the per-type lock on a background ticker while
// fn runs. The TTL is a crash-detection window, not a task time limit: an
// arbitrarily long handler stays alive as long as the heartbeat succeeds.
func runWithLeaseHeartbeat(task *model.SystemTask, runnerID string, fn func(ctx context.Context)) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
interval := systemTaskLockTTL / 3
if interval <= 0 {
interval = systemTaskLockTTL
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
if err := model.RenewSystemTaskLock(task.TaskID, runnerID, systemTaskLockUntil()); err != nil {
cancel()
return
}
}
}
}()
fn(ctx)
close(done)
}
func runLogCleanupTask(ctx context.Context, task *model.SystemTask, runnerID string) {
......@@ -136,7 +362,7 @@ func runLogCleanupTask(ctx context.Context, task *model.SystemTask, runnerID str
return
}
syncLogCleanupStateFromRemaining(&state, remaining)
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state, systemTaskLockUntil()); err != nil {
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
......@@ -171,7 +397,7 @@ func runLogCleanupTask(ctx context.Context, task *model.SystemTask, runnerID str
}
state.Progress = logCleanupProgress(state.Processed, state.Total)
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state, systemTaskLockUntil()); err != nil {
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
......@@ -188,7 +414,7 @@ func runLogCleanupTask(ctx context.Context, task *model.SystemTask, runnerID str
if state.Total < state.Processed {
state.Total = state.Processed
}
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state, systemTaskLockUntil()); err != nil {
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
......@@ -233,6 +459,55 @@ func systemTaskLockUntil() int64 {
return common.GetTimestamp() + int64(systemTaskLockTTL.Seconds())
}
// SystemTaskProgress is the state shape used by handlers that report percentage
// progress (channel test, model update). The frontend reads the progress field
// (0-100) to render a per-task progress indicator.
type SystemTaskProgress struct {
Total int `json:"total"`
Processed int `json:"processed"`
Progress int `json:"progress"`
}
// NewSystemTaskProgressReporter returns a throttled progress callback bound to a
// running task. Handlers call it with (processed, total) as they iterate work;
// it persists a {processed,total,progress} state at most once every ~2s, always
// emitting the first update and the final 100%.
// Lock-loss errors are ignored: the lease heartbeat cancels the handler ctx on
// loss, so progress writes are best-effort and never abort the run themselves.
// The returned func is single-goroutine only (call it from the handler loop).
func NewSystemTaskProgressReporter(task *model.SystemTask, runnerID string) func(processed, total int) {
const minWriteInterval = 2 * time.Second
var (
lastWriteAt time.Time
lastProgress = -1
)
return func(processed, total int) {
progress := 100
if total > 0 {
progress = processed * 100 / total
}
if progress < 0 {
progress = 0
} else if progress > 100 {
progress = 100
}
if progress < 100 {
if progress == lastProgress {
return
}
if !lastWriteAt.IsZero() && time.Since(lastWriteAt) < minWriteInterval {
return
}
}
lastProgress = progress
lastWriteAt = time.Now()
state := SystemTaskProgress{Total: total, Processed: processed, Progress: progress}
_ = model.UpdateSystemTaskState(task.TaskID, runnerID, state)
}
}
func failSystemTask(task *model.SystemTask, runnerID string, err error) {
logger.LogWarn(context.Background(), fmt.Sprintf("system task %s failed: %v", task.TaskID, err))
if finishErr := model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusFailed, nil, err.Error()); finishErr != nil {
......
package service
import (
"context"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withSystemTaskRegistry swaps the package registry for the given handlers for
// the duration of a test and restores the original registry afterward.
func withSystemTaskRegistry(t *testing.T, handlers ...SystemTaskHandler) {
t.Helper()
systemTaskHandlersMu.Lock()
saved := systemTaskHandlers
systemTaskHandlers = map[string]SystemTaskHandler{}
for _, h := range handlers {
systemTaskHandlers[h.Type()] = h
}
systemTaskHandlersMu.Unlock()
t.Cleanup(func() {
systemTaskHandlersMu.Lock()
systemTaskHandlers = saved
systemTaskHandlersMu.Unlock()
})
}
type stubScheduledHandler struct {
taskType string
enabled bool
interval time.Duration
onRun func(ctx context.Context, task *model.SystemTask, runnerID string)
}
type stubSystemTaskRunResult struct {
taskID string
taskType string
err error
}
func (h *stubScheduledHandler) Type() string { return h.taskType }
func (h *stubScheduledHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
if h.onRun != nil {
h.onRun(ctx, task, runnerID)
}
}
func (h *stubScheduledHandler) Enabled() bool { return h.enabled }
func (h *stubScheduledHandler) Interval() time.Duration { return h.interval }
func (h *stubScheduledHandler) NewPayload() any { return nil }
func countSystemTasks(t *testing.T, taskType string) int64 {
t.Helper()
var count int64
require.NoError(t, model.DB.Model(&model.SystemTask{}).Where("type = ?", taskType).Count(&count).Error)
return count
}
func TestSystemTaskSchedulerCreatesWhenDueAndDedups(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_scheduled", enabled: true, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// An active (pending) row already exists, so a second pass must not create
// another row.
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Finish the run; with a fresh updated_at the next run is not due yet.
latest, err := model.GetLatestSystemTask(handler.taskType)
require.NoError(t, err)
require.NotNil(t, latest)
_, claimed, err := model.ClaimSystemTask(latest.ID, handler.taskType, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(latest.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Backdate the finished row beyond the interval -> the job becomes due again.
require.NoError(t, model.DB.Model(&model.SystemTask{}).
Where("task_id = ?", latest.TaskID).
Update("updated_at", common.GetTimestamp()-120).Error)
runSystemTaskScheduler()
require.Equal(t, int64(2), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskSchedulerSkipsDisabled(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_disabled", enabled: false, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskClaimPassDispatchesByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 1)
handler := &stubScheduledHandler{
taskType: "test_dispatch",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskType: task.Type,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handler)
_, err := model.CreateSystemTask(handler.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
select {
case got := <-ran:
require.NoError(t, got.err)
assert.Equal(t, handler.taskType, got.taskType)
case <-time.After(2 * time.Second):
t.Fatal("claimed task was not dispatched to its handler")
}
require.Eventually(t, func() bool {
latest, err := model.GetLatestSystemTask(handler.taskType)
return err == nil && latest != nil && latest.Status == model.SystemTaskStatusSucceeded
}, 2*time.Second, 20*time.Millisecond)
}
func TestSystemTaskClaimPassDispatchesEarliestPendingByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 2)
handlerA := &stubScheduledHandler{
taskType: "test_dispatch_a",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
handlerB := &stubScheduledHandler{
taskType: "test_dispatch_b",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handlerA, handlerB)
firstA, err := model.CreateSystemTask(handlerA.taskType, nil, nil)
require.NoError(t, err)
secondTaskID, err := model.GenerateSystemTaskID()
require.NoError(t, err)
secondA := &model.SystemTask{
TaskID: secondTaskID,
Type: handlerA.taskType,
Status: model.SystemTaskStatusPending,
}
require.NoError(t, model.DB.Create(secondA).Error)
firstB, err := model.CreateSystemTask(handlerB.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
got := map[string]bool{}
for range 2 {
select {
case result := <-ran:
require.NoError(t, result.err)
got[result.taskID] = true
case <-time.After(2 * time.Second):
t.Fatal("claimed tasks were not dispatched to their handlers")
}
}
assert.True(t, got[firstA.TaskID])
assert.True(t, got[firstB.TaskID])
assert.False(t, got[secondA.TaskID])
require.Eventually(t, func() bool {
reloaded, err := model.GetSystemTaskByTaskID(secondA.TaskID)
return err == nil && reloaded != nil && reloaded.Status == model.SystemTaskStatusPending
}, 2*time.Second, 20*time.Millisecond)
}
func TestEnqueueSystemTaskReportsCreatedAndExistingActive(t *testing.T) {
truncate(t)
first, created, err := EnqueueSystemTask("test_enqueue", map[string]bool{"manual": true})
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, first)
existing, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.False(t, created)
require.NotNil(t, existing)
assert.Equal(t, first.TaskID, existing.TaskID)
_, claimed, err := model.ClaimSystemTask(first.ID, first.Type, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(first.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
second, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, second)
assert.NotEqual(t, first.TaskID, second.TaskID)
}
......@@ -45,6 +45,7 @@ func TestMain(m *testing.M) {
&model.TopUp{},
&model.UserSubscription{},
&model.SystemTask{},
&model.SystemTaskLock{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
......@@ -66,6 +67,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions")
model.DB.Exec("DELETE FROM system_task_locks")
model.DB.Exec("DELETE FROM system_tasks")
})
}
......
......@@ -87,65 +87,101 @@ func sweepTimedOutTasks(ctx context.Context) {
}
}
// TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务
func TaskPollingLoop() {
for {
time.Sleep(time.Duration(15) * time.Second)
common.SysLog("任务进度轮询开始")
ctx := context.TODO()
sweepTimedOutTasks(ctx)
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
platformTask := make(map[constant.TaskPlatform][]*model.Task)
for _, t := range allTasks {
platformTask[t.Platform] = append(platformTask[t.Platform], t)
}
for platform, tasks := range platformTask {
if len(tasks) == 0 {
// TaskPollSummary is the result recorded on an async_task_poll system task row,
// summarizing one polling pass.
type TaskPollSummary struct {
UnfinishedTasks int `json:"unfinished_tasks"`
PlatformsScanned int `json:"platforms_scanned"`
NullTasksFailed int `json:"null_tasks_failed"`
}
// RunTaskPollingOnce performs one async-task (Suno/video) polling pass
// synchronously. It honors ctx cancellation (the system-task runner cancels it
// when the lease is lost) and, when report is non-nil, reports progress as
// (processedPlatforms, totalPlatforms). It returns immediately if the task
// adaptor factory has not been wired yet, to avoid a nil call during startup.
func RunTaskPollingOnce(ctx context.Context, report func(processed, total int)) TaskPollSummary {
summary := TaskPollSummary{}
if GetTaskAdaptorFunc == nil {
return summary
}
if ctx == nil {
ctx = context.Background()
}
common.SysLog("任务进度轮询开始")
sweepTimedOutTasks(ctx)
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
summary.UnfinishedTasks = len(allTasks)
platformTask := make(map[constant.TaskPlatform][]*model.Task)
for _, t := range allTasks {
platformTask[t.Platform] = append(platformTask[t.Platform], t)
}
totalPlatforms := len(platformTask)
processedPlatforms := 0
for platform, tasks := range platformTask {
if ctx.Err() != nil {
break
}
if report != nil {
report(processedPlatforms, totalPlatforms)
}
processedPlatforms++
if len(tasks) == 0 {
continue
}
summary.PlatformsScanned++
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Task)
nullTaskIds := make([]int64, 0)
for _, task := range tasks {
upstreamID := task.GetUpstreamTaskID()
if upstreamID == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.ID)
continue
}
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Task)
nullTaskIds := make([]int64, 0)
for _, task := range tasks {
upstreamID := task.GetUpstreamTaskID()
if upstreamID == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.ID)
continue
}
taskM[upstreamID] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
}
if len(nullTaskIds) > 0 {
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
continue
taskM[upstreamID] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
}
if len(nullTaskIds) > 0 {
summary.NullTasksFailed += len(nullTaskIds)
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
}
DispatchPlatformUpdate(platform, taskChannelM, taskM)
}
common.SysLog("任务进度轮询完成")
if len(taskChannelM) == 0 {
continue
}
DispatchPlatformUpdate(ctx, platform, taskChannelM, taskM)
}
if report != nil && ctx.Err() == nil {
report(totalPlatforms, totalPlatforms)
}
common.SysLog("任务进度轮询完成")
return summary
}
// DispatchPlatformUpdate 按平台分发轮询更新
func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
func DispatchPlatformUpdate(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
if ctx == nil {
ctx = context.Background()
}
switch platform {
case constant.TaskPlatformMidjourney:
// MJ 轮询由其自身处理,这里预留入口
case constant.TaskPlatformSuno:
_ = UpdateSunoTasks(context.Background(), taskChannelM, taskM)
_ = UpdateSunoTasks(ctx, taskChannelM, taskM)
default:
if err := UpdateVideoTasks(context.Background(), platform, taskChannelM, taskM); err != nil {
if err := UpdateVideoTasks(ctx, platform, taskChannelM, taskM); err != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
}
}
......@@ -154,6 +190,9 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int
// UpdateSunoTasks 按渠道更新所有 Suno 任务
func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
err := updateSunoTasks(ctx, channelId, taskIds, taskM)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
......@@ -164,6 +203,9 @@ func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM m
func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
}
if len(taskIds) == 0 {
return nil
}
......@@ -221,7 +263,14 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
}
for _, responseItem := range responseItems.Data {
if ctx.Err() != nil {
return ctx.Err()
}
task := taskM[responseItem.TaskID]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Suno task response ignored: unknown task_id=%s", responseItem.TaskID))
continue
}
if !taskNeedsUpdate(task, responseItem) {
continue
}
......@@ -290,6 +339,9 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool {
// UpdateVideoTasks 按渠道更新所有视频任务
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
......@@ -332,16 +384,26 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
for _, taskId := range taskIds {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
// sleep 1 second between each task to avoid hitting rate limits of upstream platforms
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
return nil
}
func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error {
if ctx.Err() != nil {
return ctx.Err()
}
baseURL := constant.ChannelBaseURLs[ch.Type]
if ch.GetBaseURL() != "" {
baseURL = ch.GetBaseURL()
......
......@@ -28,6 +28,12 @@ type BaseNavItem = {
icon?: React.ElementType
activeUrls?: (LinkProps['to'] | (string & {}))[]
configUrls?: (LinkProps['to'] | (string & {}))[]
/**
* Minimum role required to see this item in the sidebar. When set, the item
* is hidden for users whose role is below this threshold (see
* `useSidebarView`). Route-level guards still enforce access independently.
*/
requiredRole?: number
}
/**
......
......@@ -251,7 +251,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
const { success, message } = res.data || {}
if (!success) {
toast.error(message || t('Batch detection failed'))
return
......@@ -259,13 +259,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
toast.success(
t(
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
{
channels: data?.processed_channels || 0,
add: data?.detected_add_models || 0,
remove: data?.detected_remove_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
)
)
await refresh()
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { ListChecks, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { ErrorState } from '@/components/error-state'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { listSystemTasks } from '@/features/system-settings/api'
import type {
SystemTask,
SystemTaskStatus,
} from '@/features/system-settings/types'
const TASK_LIMIT = 20
const ACTIVE_POLL_INTERVAL_MS = 8000
const STATUS_VARIANT: Record<SystemTaskStatus, 'secondary' | 'destructive'> = {
pending: 'secondary',
running: 'secondary',
succeeded: 'secondary',
failed: 'destructive',
}
const STATUS_CLASS_NAME: Record<SystemTaskStatus, string> = {
pending: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
running:
'bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300 [&_span]:bg-sky-500',
succeeded:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
failed: '',
}
const STATUS_DOT_CLASS_NAME: Record<SystemTaskStatus, string> = {
pending: 'bg-amber-500',
running: 'bg-sky-500',
succeeded: 'bg-emerald-500',
failed: 'bg-destructive',
}
const PROGRESS_BAR_CLASS_NAME: Record<SystemTaskStatus, string> = {
pending: '[&_[data-slot=progress-indicator]]:bg-amber-500',
running: '[&_[data-slot=progress-indicator]]:bg-sky-500',
succeeded: '[&_[data-slot=progress-indicator]]:bg-emerald-500',
failed: '[&_[data-slot=progress-indicator]]:bg-destructive',
}
// Maps backend system task type constants to i18n source keys. Unknown/future
// types fall back to their raw identifier so the panel never shows blank.
const TYPE_LABEL: Record<string, string> = {
log_cleanup: 'Log cleanup',
channel_test: 'Batch channel test',
model_update: 'Batch upstream model update',
midjourney_poll: 'Midjourney task polling',
async_task_poll: 'Async task polling',
}
function isActiveStatus(status: SystemTaskStatus) {
return status === 'pending' || status === 'running'
}
function getProgress(task: SystemTask): number | null {
const progress = (task.state as { progress?: unknown } | undefined)?.progress
if (typeof progress !== 'number' || Number.isNaN(progress)) return null
return Math.min(100, Math.max(0, progress))
}
type SystemTasksTableProps = {
tasks: SystemTask[]
}
function SystemTasksTable(props: SystemTasksTableProps) {
const { t } = useTranslation()
return (
<div className='overflow-x-auto rounded-md border'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='bg-muted/40 hover:bg-muted/40'>
<TableHead className='h-9 w-[260px] px-4 text-xs'>
{t('Type')}
</TableHead>
<TableHead className='h-9 w-[130px] text-xs'>
{t('Status')}
</TableHead>
<TableHead className='h-9 w-[180px] text-xs'>
{t('Progress')}
</TableHead>
<TableHead className='h-9 min-w-[260px] text-xs'>
{t('Executor')}
</TableHead>
<TableHead className='h-9 w-[190px] text-xs'>
{t('Updated')}
</TableHead>
<TableHead className='h-9 w-[220px] pr-4 text-xs'>
{t('Detail')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.tasks.map((task) => {
const progress = getProgress(task)
return (
<TableRow key={task.task_id} className='hover:bg-muted/30'>
<TableCell className='px-4 py-3 align-middle'>
<div className='space-y-0.5'>
<div className='font-medium'>
{t(TYPE_LABEL[task.type] ?? task.type)}
</div>
<div className='text-muted-foreground font-mono text-[11px]'>
{task.type}
</div>
</div>
</TableCell>
<TableCell className='py-3 align-middle'>
<Badge
variant={STATUS_VARIANT[task.status]}
className={cn('gap-1.5', STATUS_CLASS_NAME[task.status])}
>
<span
className={cn(
'size-1.5 rounded-full',
STATUS_DOT_CLASS_NAME[task.status]
)}
aria-hidden='true'
/>
{t(task.status)}
</Badge>
</TableCell>
<TableCell className='py-3 align-middle'>
<div className='flex items-center gap-2'>
<Progress
value={progress ?? 0}
className={cn(
'w-24',
PROGRESS_BAR_CLASS_NAME[task.status]
)}
/>
<span className='text-muted-foreground w-10 text-right text-xs tabular-nums'>
{progress === null ? '-' : `${progress}%`}
</span>
</div>
</TableCell>
<TableCell className='text-muted-foreground max-w-[280px] truncate py-3 font-mono text-xs align-middle'>
{task.locked_by || '-'}
</TableCell>
<TableCell className='text-muted-foreground py-3 text-xs whitespace-nowrap align-middle'>
{formatTimestampToDate(task.updated_at)}
</TableCell>
<TableCell
className='text-destructive max-w-[220px] truncate py-3 pr-4 text-xs align-middle'
title={task.error || undefined}
>
{task.error || '-'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
export function SystemTasksPanel() {
const { t } = useTranslation()
const tasksQuery = useQuery({
queryKey: ['system-info', 'system-tasks'],
queryFn: async () => {
const res = await listSystemTasks(TASK_LIMIT)
if (!res.success || !Array.isArray(res.data)) {
throw new Error(res.message || t('We could not load system tasks.'))
}
return res.data
},
staleTime: 30 * 1000,
retry: false,
refetchInterval: (query) =>
query.state.data?.some((task) => isActiveStatus(task.status))
? ACTIVE_POLL_INTERVAL_MS
: false,
})
const tasks = tasksQuery.data ?? []
const loading = tasksQuery.isLoading
const refreshing = tasksQuery.isFetching && !tasksQuery.isLoading
const hasActiveTasks = tasks.some((task) => isActiveStatus(task.status))
const activeTasks = tasks.filter((task) => isActiveStatus(task.status))
const historyTasks = tasks.filter((task) => !isActiveStatus(task.status))
return (
<section className='bg-card overflow-hidden rounded-lg border shadow-xs'>
<div className='flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='bg-muted text-muted-foreground inline-flex size-7 items-center justify-center rounded-md'>
<ListChecks className='size-4' aria-hidden='true' />
</span>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'>{t('System Tasks')}</h3>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t(
'Recent maintenance tasks running across instances and their execution status.'
)}
</p>
</div>
</div>
</div>
<div className='flex shrink-0 items-center gap-3'>
<span
className='text-muted-foreground inline-flex items-center gap-1.5 text-xs'
aria-live='polite'
>
<span
className={cn(
'size-1.5 rounded-full',
hasActiveTasks ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
aria-hidden='true'
/>
{hasActiveTasks
? t('Auto-refreshing every {{seconds}}s', {
seconds: ACTIVE_POLL_INTERVAL_MS / 1000,
})
: t('Live refresh pauses when no task is running')}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => void tasksQuery.refetch()}
disabled={tasksQuery.isFetching}
aria-label={t('Refresh')}
>
<RefreshCw
data-icon='inline-start'
className={cn('size-3.5', refreshing && 'animate-spin')}
aria-hidden='true'
/>
{refreshing ? t('Refreshing...') : t('Refresh')}
</Button>
</div>
</div>
<div aria-busy={tasksQuery.isFetching}>
{loading ? (
<div className='space-y-2 p-4 sm:p-5'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-9 w-full rounded-md' />
))}
</div>
) : tasksQuery.isError ? (
<ErrorState
title={t('We could not load system tasks.')}
description={
tasksQuery.error instanceof Error
? tasksQuery.error.message
: undefined
}
onRetry={() => {
void tasksQuery.refetch()
}}
className='min-h-[260px]'
/>
) : tasks.length === 0 ? (
<div className='px-4 py-10 text-center sm:px-5'>
<div className='bg-muted mx-auto mb-3 flex size-10 items-center justify-center rounded-lg'>
<ListChecks
className='text-muted-foreground size-5'
aria-hidden='true'
/>
</div>
<p className='text-muted-foreground text-sm'>
{t('No system tasks yet.')}
</p>
</div>
) : (
<div className='space-y-4 p-4 sm:p-5'>
<div>
<div className='mb-2 flex items-center justify-between gap-3'>
<div>
<h4 className='text-sm font-medium'>{t('Active Tasks')}</h4>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t('Tasks currently pending or running.')}
</p>
</div>
<Badge variant='outline'>{activeTasks.length}</Badge>
</div>
{activeTasks.length > 0 ? (
<SystemTasksTable tasks={activeTasks} />
) : (
<div className='text-muted-foreground rounded-md border border-dashed px-4 py-6 text-center text-sm'>
{t('No active system tasks.')}
</div>
)}
</div>
<div>
<div className='mb-2 flex items-center justify-between gap-3'>
<div>
<h4 className='text-sm font-medium'>{t('Task History')}</h4>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t('Recently completed or failed system task runs.')}
</p>
</div>
<Badge variant='outline'>{historyTasks.length}</Badge>
</div>
{historyTasks.length > 0 ? (
<SystemTasksTable tasks={historyTasks} />
) : (
<div className='text-muted-foreground rounded-md border border-dashed px-4 py-6 text-center text-sm'>
{t('No historical system tasks.')}
</div>
)}
</div>
</div>
)}
</div>
</section>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { SystemTasksPanel } from './components/system-tasks-panel'
export function SystemInfo() {
const { t } = useTranslation()
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t('System Info')}</SectionPageLayout.Title>
<SectionPageLayout.Content>
<SystemTasksPanel />
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
......@@ -22,6 +22,7 @@ import type {
FetchUpstreamRatiosRequest,
LogCleanupTask,
SystemOptionsResponse,
SystemTaskListResponse,
SystemTaskResponse,
UpdateOptionRequest,
UpdateOptionResponse,
......@@ -75,6 +76,13 @@ export async function getSystemTask(taskId: string) {
return res.data
}
export async function listSystemTasks(limit = 20) {
const res = await api.get<SystemTaskListResponse>('/api/system-task/list', {
params: { limit },
})
return res.data
}
export async function resetModelRatios() {
const res = await api.post<UpdateOptionResponse>(
'/api/option/rest_model_ratio'
......
......@@ -100,6 +100,12 @@ export type SystemTaskResponse<TTask = SystemTask | null> = {
data?: TTask
}
export type SystemTaskListResponse = {
success: boolean
message: string
data?: SystemTask[]
}
export type SiteSettings = {
'theme.frontend': string
Notice: string
......
......@@ -27,6 +27,7 @@ import {
ListTodo,
MessageSquare,
Radio,
ServerCog,
Settings,
Ticket,
User,
......@@ -34,6 +35,7 @@ import {
Wallet,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ROLE } from '@/lib/roles'
import { type SidebarData } from '@/components/layout/types'
/**
......@@ -142,6 +144,12 @@ export function useSidebarData(): SidebarData {
icon: CreditCard,
},
{
title: t('System Info'),
url: '/system-info',
icon: ServerCog,
requiredRole: ROLE.SUPER_ADMIN,
},
{
title: t('System Settings'),
url: '/system-settings/site',
activeUrls: ['/system-settings'],
......
......@@ -50,10 +50,16 @@ export function useSidebarView(): ResolvedSidebarView {
const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups)
const rootNavGroups = useMemo<NavGroup[]>(() => {
const isAdmin = userRole !== undefined && userRole >= ROLE.ADMIN
return configFilteredRoot.filter((group) =>
group.id === 'admin' ? isAdmin : true
)
const role = userRole ?? ROLE.GUEST
const isAdmin = role >= ROLE.ADMIN
return configFilteredRoot
.filter((group) => (group.id === 'admin' ? isAdmin : true))
.map((group) => {
const items = group.items.filter(
(item) => item.requiredRole === undefined || role >= item.requiredRole
)
return items.length === group.items.length ? group : { ...group, items }
})
}, [configFilteredRoot, userRole])
const view = resolveSidebarView(pathname)
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "Active Cache Count",
"Active Files": "Active Files",
"Active models": "Active models",
"Active Tasks": "Active Tasks",
"active users": "active users",
"Actual Amount": "Actual Amount",
"Actual Model": "Actual Model",
......@@ -428,6 +429,7 @@
"Ask anything": "Ask anything",
"Assigned by administrator only": "Assigned by administrator only",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Assigned by administrators and used to represent a user level, such as default or vip.",
"Async task polling": "Async task polling",
"Async task refund": "Async task refund",
"At least one model regex pattern is required": "At least one model regex pattern is required",
"At least one valid key source is required": "At least one valid key source is required",
......@@ -483,6 +485,7 @@
"Auto-discover": "Auto-discover",
"Auto-discovers endpoints from the provider": "Auto-discovers endpoints from the provider",
"Auto-fill when one field exists and another is missing": "Auto-fill when one field exists and another is missing",
"Auto-refreshing every {{seconds}}s": "Auto-refreshing every {{seconds}}s",
"Auto-retry status codes": "Auto-retry status codes",
"Automatically disable channel on repeated failures": "Automatically disable channel on repeated failures",
"Automatically disable channels exceeding this response time": "Automatically disable channels exceeding this response time",
......@@ -553,6 +556,7 @@
"Basic Information": "Basic Information",
"Basic Templates": "Basic Templates",
"Batch Add (one key per line)": "Batch Add (one key per line)",
"Batch channel test": "Batch channel test",
"Batch delete failed": "Batch delete failed",
"Batch deleted {{count}} channels": "Batch deleted {{count}} channels",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "Batch test completed: {{success}} succeeded, {{failed}} failed",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed",
"Batch testing models...": "Batch testing models...",
"Batch upstream model update": "Batch upstream model update",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "Best for single-tenant deployments. Pricing and billing options stay hidden.",
"Best TTFT": "Best TTFT",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "Designed and Developed by",
"designed for scale": "designed for scale",
"Destroyed": "Destroyed",
"Detail": "Detail",
"Detailed request logs for investigations.": "Detailed request logs for investigations.",
"Details": "Details",
"Detect All Upstream Updates": "Detect All Upstream Updates",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "Exchange rate is required",
"Exchange rate must be greater than 0": "Exchange rate must be greater than 0",
"Execute code in a sandbox during the response": "Execute code in a sandbox during the response",
"Executor": "Executor",
"Exhausted": "Exhausted",
"Existing account will be reused": "Existing account will be reused",
"Existing Models ({{count}})": "Existing Models ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "extras",
"Fail Reason": "Fail Reason",
"Fail Reason Details": "Fail Reason Details",
"failed": "failed",
"Failed": "Failed",
"Failed to {{action}} user": "Failed to {{action}} user",
"Failed to adjust quota": "Failed to adjust quota",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "List of models supported by this channel. Use comma to separate multiple models.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "List of origins (one per line) allowed for Passkey registration and authentication.",
"List view": "List view",
"Live refresh pauses when no task is running": "Live refresh pauses when no task is running",
"LLM Leaderboard": "LLM Leaderboard",
"LLM prompt helper": "LLM prompt helper",
"Load Balancing": "Load Balancing",
......@@ -2356,6 +2365,7 @@
"Locations": "Locations",
"Locked": "Locked",
"log": "log",
"Log cleanup": "Log cleanup",
"Log cleanup progress": "Log cleanup progress",
"Log cleanup task started.": "Log cleanup task started.",
"Log Details": "Log Details",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "Merge into Other",
"Message Priority": "Message Priority",
"Metadata": "Metadata",
"Midjourney task polling": "Midjourney task polling",
"min downtime": "min downtime",
"Min Top-up": "Min Top-up",
"Min Top-up:": "Min Top-up:",
......@@ -2653,6 +2664,7 @@
"No": "No",
"No About Content Set": "No About Content Set",
"No Active": "No Active",
"No active system tasks.": "No active system tasks.",
"No additional type-specific settings for this channel type.": "No additional type-specific settings for this channel type.",
"No amount options configured. Add amounts below to get started.": "No amount options configured. Add amounts below to get started.",
"No announcements at this time": "No announcements at this time",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "No groups match your search",
"No groups yet. Add a group to get started.": "No groups yet. Add a group to get started.",
"No header overrides configured.": "No header overrides configured.",
"No historical system tasks.": "No historical system tasks.",
"No history data available": "No history data available",
"No incidents in the last 24 hours": "No incidents in the last 24 hours",
"No incidents in the last 30 days": "No incidents in the last 30 days",
......@@ -2787,6 +2800,7 @@
"No subscription records": "No subscription records",
"No Sync": "No Sync",
"No system announcements": "No system announcements",
"No system tasks yet.": "No system tasks yet.",
"No token found.": "No token found.",
"No tools configured": "No tools configured",
"No Upgrade": "No Upgrade",
......@@ -3083,6 +3097,7 @@
"Peak": "Peak",
"Peak throughput": "Peak throughput",
"Penalises repetition of frequent tokens": "Penalises repetition of frequent tokens",
"pending": "pending",
"Pending": "Pending",
"per": "per",
"Per 1K tokens": "Per 1K tokens",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications",
"Received": "Received",
"Received amount": "Received amount",
"Recent maintenance tasks running across instances and their execution status.": "Recent maintenance tasks running across instances and their execution status.",
"Recently completed or failed system task runs.": "Recently completed or failed system task runs.",
"Recently launched models": "Recently launched models",
"Recently launched models gaining traction": "Recently launched models gaining traction",
"Recharge": "Recharge",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "Rules JSON must be an array",
"Run GC": "Run GC",
"Run tests for the selected models": "Run tests for the selected models",
"running": "running",
"Running": "Running",
"Runway": "Runway",
"s": "s",
......@@ -3883,11 +3901,11 @@
"Shorten": "Shorten",
"Show": "Show",
"Show All": "Show All",
"Show sensitive data": "Show sensitive data",
"Show all providers including unbound": "Show all providers including unbound",
"Show only bound providers": "Show only bound providers",
"Show or hide flow columns": "Show or hide flow columns",
"Show prices in currency instead of quota.": "Show prices in currency instead of quota.",
"Show sensitive data": "Show sensitive data",
"Show setup guide": "Show setup guide",
"Show token usage statistics in the UI": "Show token usage statistics in the UI",
"Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "Subscription purchased successfully",
"Subscriptions": "Subscriptions",
"Subtract": "Subtract",
"succeeded": "succeeded",
"Success": "Success",
"Success rate": "Success rate",
"Successfully created {{count}} API Key(s)": "Successfully created {{count}} API Key(s)",
......@@ -4078,6 +4097,7 @@
"System Behavior": "System Behavior",
"System data statistics": "System data statistics",
"System default": "System default",
"System Info": "System Info",
"System Information": "System Information",
"System initialized successfully! Redirecting…": "System initialized successfully! Redirecting…",
"System logo": "System logo",
......@@ -4096,6 +4116,7 @@
"System Settings": "System Settings",
"System setup wizard": "System setup wizard",
"System task records": "System task records",
"System Tasks": "System Tasks",
"System Version": "System Version",
"Table view": "Table view",
"Tag": "Tag",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "Target Path (optional)",
"Target User": "Target User",
"Task": "Task",
"Task History": "Task History",
"Task ID": "Task ID",
"Task ID:": "Task ID:",
"Task logs": "Task logs",
"Task Logs": "Task Logs",
"Tasks currently pending or running.": "Tasks currently pending or running.",
"Team Collaboration": "Team Collaboration",
"Technical Support": "Technical Support",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "Upstream",
"Upstream did not return reset credit details.": "Upstream did not return reset credit details.",
"Upstream Model Detection Settings": "Upstream Model Detection Settings",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.",
"Upstream Model Update Check": "Upstream Model Update Check",
"Upstream Model Updates": "Upstream Model Updates",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "Warning: This action is permanent and irreversible!",
"We apologize for the inconvenience.": "We apologize for the inconvenience.",
"We could not load the setup status.": "We could not load the setup status.",
"We could not load system tasks.": "We could not load system tasks.",
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
"We'll be back online shortly.": "We'll be back online shortly.",
"Web search": "Web search",
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "Nombre de caches actifs",
"Active Files": "Fichiers actifs",
"Active models": "Modèles actifs",
"Active Tasks": "Tâches actives",
"active users": "utilisateurs actifs",
"Actual Amount": "Montant réel",
"Actual Model": "Modèle réel",
......@@ -428,6 +429,7 @@
"Ask anything": "Demandez n'importe quoi",
"Assigned by administrator only": "Attribué uniquement par l'administrateur",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Attribué par les administrateurs pour représenter un niveau utilisateur, comme default ou vip.",
"Async task polling": "Interrogation des tâches asynchrones",
"Async task refund": "Remboursement de tâche asynchrone",
"At least one model regex pattern is required": "Au moins un modèle de regex est requis",
"At least one valid key source is required": "Au moins une source de clé valide est requise",
......@@ -483,6 +485,7 @@
"Auto-discover": "Découverte automatique",
"Auto-discovers endpoints from the provider": "Découvre automatiquement les points de terminaison du fournisseur",
"Auto-fill when one field exists and another is missing": "Remplissage automatique si un champ existe et l'autre est manquant",
"Auto-refreshing every {{seconds}}s": "Actualisation automatique toutes les {{seconds}} s",
"Auto-retry status codes": "Codes de statut de nouvelle tentative auto",
"Automatically disable channel on repeated failures": "Désactiver automatiquement le canal en cas d'échecs répétés",
"Automatically disable channels exceeding this response time": "Désactiver automatiquement les canaux dépassant ce temps de réponse",
......@@ -553,6 +556,7 @@
"Basic Information": "Informations de base",
"Basic Templates": "Modèles de base",
"Batch Add (one key per line)": "Ajout par lots (une clé par ligne)",
"Batch channel test": "Test groupé des canaux",
"Batch delete failed": "Échec de la suppression par lots",
"Batch deleted {{count}} channels": "{{count}} canaux supprimés par lot",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Détection par lots terminée : {{channels}} canaux, {{add}} à ajouter, {{remove}} à supprimer, {{fails}} échoués",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "Test par lots terminé : {{success}} réussi(s), {{failed}} échoué(s)",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "Test par lots arrêté : {{completed}}/{{total}} terminé(s), {{success}} réussi(s), {{failed}} échoué(s)",
"Batch testing models...": "Test des modèles par lots...",
"Batch upstream model update": "Mise à jour groupée des modèles en amont",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Mises à jour par lot des modèles en amont appliquées : {{channels}} canaux, {{added}} ajoutés, {{removed}} supprimés, {{fails}} échoués",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "Idéal pour les déploiements mono-utilisateur. Les options de tarification et de facturation restent masquées.",
"Best TTFT": "Meilleur TTFT",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "Conçu et développé par",
"designed for scale": "conçu pour la scalabilité",
"Destroyed": "Détruit",
"Detail": "Détail",
"Detailed request logs for investigations.": "Journaux détaillés des requêtes pour les enquêtes.",
"Details": "Détails",
"Detect All Upstream Updates": "Détecter toutes les mises à jour upstream",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "Le taux de change est requis",
"Exchange rate must be greater than 0": "Le taux de change doit être supérieur à 0",
"Execute code in a sandbox during the response": "Exécuter du code dans un bac à sable pendant la réponse",
"Executor": "Exécuteur",
"Exhausted": "Épuisé",
"Existing account will be reused": "Le compte existant sera réutilisé",
"Existing Models ({{count}})": "Modèles existants ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "suppléments",
"Fail Reason": "Raison de l'échec",
"Fail Reason Details": "Détails de la raison de l'échec",
"failed": "échoué",
"Failed": "Échec",
"Failed to {{action}} user": "Échec de l'action {{action}} sur l'utilisateur",
"Failed to adjust quota": "Échec de l'ajustement du quota",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Liste des modèles pris en charge par ce canal. Utilisez une virgule pour séparer plusieurs modèles.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Liste des origines (une par ligne) autorisées pour l'enregistrement et l'authentification des clés d'accès (Passkey).",
"List view": "Vue en liste",
"Live refresh pauses when no task is running": "L'actualisation en direct est suspendue lorsqu'aucune tâche n'est en cours",
"LLM Leaderboard": "Classement des LLM",
"LLM prompt helper": "Assistant prompt LLM",
"Load Balancing": "Équilibrage de charge",
......@@ -2356,6 +2365,7 @@
"Locations": "Emplacements",
"Locked": "Verrouillé",
"log": "journal",
"Log cleanup": "Nettoyage des journaux",
"Log cleanup progress": "Progression du nettoyage des journaux",
"Log cleanup task started.": "La tâche de nettoyage des journaux a démarré.",
"Log Details": "Détails du journal",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "Fusionner dans Autres",
"Message Priority": "Priorité du message",
"Metadata": "Métadonnées",
"Midjourney task polling": "Interrogation des tâches Midjourney",
"min downtime": "min d'interruption",
"Min Top-up": "Recharge min.",
"Min Top-up:": "Recharge min. :",
......@@ -2653,6 +2664,7 @@
"No": "Non",
"No About Content Set": "Aucun contenu « À propos » défini",
"No Active": "Aucun actif",
"No active system tasks.": "Aucune tâche système active.",
"No additional type-specific settings for this channel type.": "Aucun paramètre supplémentaire spécifique au type pour ce type de canal.",
"No amount options configured. Add amounts below to get started.": "Aucune option de montant configurée. Ajoutez des montants ci-dessous pour commencer.",
"No announcements at this time": "Aucune annonce pour le moment",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "Aucun groupe ne correspond à votre recherche",
"No groups yet. Add a group to get started.": "Aucun groupe pour le moment. Ajoutez un groupe pour commencer.",
"No header overrides configured.": "Aucune surcharge d'en-têtes configurée.",
"No historical system tasks.": "Aucune tâche système dans l’historique.",
"No history data available": "Aucune donnée historique disponible",
"No incidents in the last 24 hours": "Aucun incident au cours des dernières 24 heures",
"No incidents in the last 30 days": "Aucun incident sur les 30 derniers jours",
......@@ -2787,6 +2800,7 @@
"No subscription records": "Aucun enregistrement d'abonnement",
"No Sync": "Pas de synchronisation",
"No system announcements": "Aucune annonce système",
"No system tasks yet.": "Aucune tâche système pour le moment.",
"No token found.": "Aucun jeton trouvé.",
"No tools configured": "Aucun outil configuré",
"No Upgrade": "Pas de mise à niveau",
......@@ -3083,6 +3097,7 @@
"Peak": "Pic",
"Peak throughput": "Débit de pointe",
"Penalises repetition of frequent tokens": "Pénalise la répétition des jetons fréquents",
"pending": "en attente",
"Pending": "En attente",
"per": "par",
"Per 1K tokens": "Par 1K tokens",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont",
"Received": "Reçu",
"Received amount": "Montant reçu",
"Recent maintenance tasks running across instances and their execution status.": "Tâches de maintenance récentes exécutées sur les instances et leur état d'exécution.",
"Recently completed or failed system task runs.": "Exécutions de tâches système récemment terminées ou échouées.",
"Recently launched models": "Modèles récemment lancés",
"Recently launched models gaining traction": "Modèles récemment publiés et en forte progression",
"Recharge": "Recharger",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "Le JSON des règles doit être un tableau",
"Run GC": "Exécuter le GC",
"Run tests for the selected models": "Exécuter les tests pour les modèles sélectionnés",
"running": "en cours",
"Running": "En cours",
"Runway": "Durée restante",
"s": "s",
......@@ -3883,11 +3901,11 @@
"Shorten": "Raccourcir",
"Show": "Afficher",
"Show All": "Tout afficher",
"Show sensitive data": "Afficher les données sensibles",
"Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)",
"Show only bound providers": "Afficher uniquement les fournisseurs liés",
"Show or hide flow columns": "Afficher ou masquer les colonnes du flux",
"Show prices in currency instead of quota.": "Afficher les prix en devise au lieu du quota.",
"Show sensitive data": "Afficher les données sensibles",
"Show setup guide": "Afficher le guide de configuration",
"Show token usage statistics in the UI": "Afficher les statistiques d'utilisation des jetons dans l'interface utilisateur",
"Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "Abonnement acheté avec succès",
"Subscriptions": "Abonnements",
"Subtract": "Soustraire",
"succeeded": "réussi",
"Success": "Succès",
"Success rate": "Taux de réussite",
"Successfully created {{count}} API Key(s)": "{{count}} clé(s) API créée(s) avec succès",
......@@ -4078,6 +4097,7 @@
"System Behavior": "Comportement du système",
"System data statistics": "Statistiques des données système",
"System default": "Système par défaut",
"System Info": "Infos système",
"System Information": "Informations système",
"System initialized successfully! Redirecting…": "Système initialisé avec succès ! Redirection…",
"System logo": "Logo du système",
......@@ -4096,6 +4116,7 @@
"System Settings": "Paramètres du système",
"System setup wizard": "Assistant de configuration du système",
"System task records": "Historique des tâches système",
"System Tasks": "Tâches système",
"System Version": "Version du système",
"Table view": "Vue en tableau",
"Tag": "Balise",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "Chemin cible (optionnel)",
"Target User": "Utilisateur cible",
"Task": "Tâche",
"Task History": "Historique des tâches",
"Task ID": "ID de la tâche",
"Task ID:": "ID de tâche :",
"Task logs": "Journaux des tâches",
"Task Logs": "Journaux de tâches",
"Tasks currently pending or running.": "Tâches actuellement en attente ou en cours d’exécution.",
"Team Collaboration": "Collaboration d'équipe",
"Technical Support": "Support technique",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "Amont",
"Upstream did not return reset credit details.": "L'amont n'a renvoyé aucun détail de crédit de réinitialisation.",
"Upstream Model Detection Settings": "Paramètres de détection des modèles en amont",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Tâche de détection des modèles en amont démarrée. Suivez la progression dans Infos système, puis actualisez pour examiner les mises à jour en attente.",
"Upstream Model Update Check": "Vérification des mises à jour des modèles en amont",
"Upstream Model Updates": "Mises à jour des modèles en amont",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Mises à jour des modèles en amont appliquées : {{added}} ajoutés, {{removed}} supprimés, {{ignored}} ignorés cette fois, {{totalIgnored}} modèles ignorés au total",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "Avertissement : Cette action est permanente et irréversible !",
"We apologize for the inconvenience.": "Nous nous excusons pour le désagrément.",
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
"We could not load system tasks.": "Impossible de charger les tâches système.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
"Web search": "Recherche web",
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "アクティブキャッシュ数",
"Active Files": "アクティブファイル",
"Active models": "アクティブなモデル",
"Active Tasks": "進行中のタスク",
"active users": "アクティブユーザー",
"Actual Amount": "実際の金額",
"Actual Model": "実際のモデル",
......@@ -428,6 +429,7 @@
"Ask anything": "何でも質問する",
"Assigned by administrator only": "管理者のみ割り当て",
"Assigned by administrators and used to represent a user level, such as default or vip.": "管理者が割り当て、default や vip などのユーザーレベルを表します。",
"Async task polling": "非同期タスクのポーリング",
"Async task refund": "非同期タスク返金",
"At least one model regex pattern is required": "少なくとも1つのモデル正規表現パターンが必要です",
"At least one valid key source is required": "少なくとも1つの有効なキーソースが必要です",
......@@ -483,6 +485,7 @@
"Auto-discover": "自動検出",
"Auto-discovers endpoints from the provider": "プロバイダーからエンドポイントを自動検出します",
"Auto-fill when one field exists and another is missing": "一方のフィールドがあり他方が欠けている場合に自動補完",
"Auto-refreshing every {{seconds}}s": "{{seconds}} 秒ごとに自動更新",
"Auto-retry status codes": "自動リトライするステータスコード",
"Automatically disable channel on repeated failures": "繰り返しの失敗でチャネルを自動的に無効にする",
"Automatically disable channels exceeding this response time": "この応答時間を超えるチャネルを自動的に無効にする",
......@@ -553,6 +556,7 @@
"Basic Information": "基本情報",
"Basic Templates": "基本テンプレート",
"Batch Add (one key per line)": "一括追加(1行に1つのキー)",
"Batch channel test": "チャネル一括テスト",
"Batch delete failed": "一括削除に失敗しました",
"Batch deleted {{count}} channels": "{{count}} 件のチャネルを一括削除しました",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "一括検出完了:{{channels}} チャネル、{{add}} 個追加、{{remove}} 個削除、{{fails}} 個失敗",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "バッチテストが完了しました: {{success}} 件成功、{{failed}} 件失敗",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "バッチテストを停止しました: {{completed}}/{{total}} 完了、{{success}} 件成功、{{failed}} 件失敗",
"Batch testing models...": "モデルをバッチテスト中...",
"Batch upstream model update": "上流モデル一括更新",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "一括上流モデル更新を処理しました:{{channels}} チャネル、{{added}} 個追加、{{removed}} 個削除、{{fails}} 個失敗",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "シングルテナント環境に最適です。料金設定や請求オプションは非表示になります。",
"Best TTFT": "最良 TTFT",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "設計・開発",
"designed for scale": "スケールのために設計",
"Destroyed": "破棄済み",
"Detail": "詳細",
"Detailed request logs for investigations.": "調査のための詳細なリクエストログ。",
"Details": "詳細",
"Detect All Upstream Updates": "すべてのアップストリーム更新を検出",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "為替レートは必須です",
"Exchange rate must be greater than 0": "為替レートは 0 より大きくする必要があります",
"Execute code in a sandbox during the response": "応答中にサンドボックスでコードを実行",
"Executor": "実行ノード",
"Exhausted": "使い切り",
"Existing account will be reused": "既存のアカウントが再利用されます",
"Existing Models ({{count}})": "既存のモデル ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "追加項目",
"Fail Reason": "失敗理由",
"Fail Reason Details": "失敗理由の詳細",
"failed": "失敗",
"Failed": "失敗",
"Failed to {{action}} user": "ユーザーの{{action}}に失敗しました",
"Failed to adjust quota": "クォータの調整に失敗しました",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "このチャネルがサポートするモデルのリストです。複数のモデルはカンマで区切ってください。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Passkeyの登録と認証が許可されているオリジン(1行に1つ)のリスト。",
"List view": "リスト表示",
"Live refresh pauses when no task is running": "実行中のタスクがない場合、自動更新は一時停止します",
"LLM Leaderboard": "LLM リーダーボード",
"LLM prompt helper": "LLMプロンプトヘルパー",
"Load Balancing": "ロードバランシング",
......@@ -2356,6 +2365,7 @@
"Locations": "場所",
"Locked": "ロック済み",
"log": "ログ",
"Log cleanup": "ログクリーンアップ",
"Log cleanup progress": "ログクリーンアップの進行状況",
"Log cleanup task started.": "ログクリーンアップタスクを開始しました。",
"Log Details": "ログの詳細",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "その他にまとめる",
"Message Priority": "メッセージの優先度",
"Metadata": "メタデータ",
"Midjourney task polling": "Midjourney タスクのポーリング",
"min downtime": "分のダウンタイム",
"Min Top-up": "最低チャージ額",
"Min Top-up:": "最小チャージ額:",
......@@ -2653,6 +2664,7 @@
"No": "いいえ",
"No About Content Set": "概要コンテンツが設定されていません",
"No Active": "アクティブなし",
"No active system tasks.": "進行中のシステムタスクはありません。",
"No additional type-specific settings for this channel type.": "このチャネルタイプには、追加のタイプ固有の設定はありません。",
"No amount options configured. Add amounts below to get started.": "金額オプションは設定されていません。開始するには、以下の金額を追加してください。",
"No announcements at this time": "現在のお知らせはありません",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "検索に一致するグループがありません",
"No groups yet. Add a group to get started.": "グループはまだありません。グループを追加して開始してください。",
"No header overrides configured.": "ヘッダーのオーバーライドが設定されていません。",
"No historical system tasks.": "システムタスク履歴はありません。",
"No history data available": "履歴データがありません",
"No incidents in the last 24 hours": "過去 24 時間にインシデントはありません",
"No incidents in the last 30 days": "過去 30 日間でインシデントはありません",
......@@ -2787,6 +2800,7 @@
"No subscription records": "サブスクリプション記録がありません",
"No Sync": "同期なし",
"No system announcements": "システムのお知らせがありません",
"No system tasks yet.": "システムタスクはまだありません。",
"No token found.": "トークンが見つかりません。",
"No tools configured": "ツールが未設定です",
"No Upgrade": "アップグレードなし",
......@@ -3083,6 +3097,7 @@
"Peak": "ピーク",
"Peak throughput": "ピークスループット",
"Penalises repetition of frequent tokens": "頻出トークンの繰り返しを抑制します",
"pending": "保留中",
"Pending": "保留中",
"per": "あたり",
"Per 1K tokens": "1Kトークンあたり",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る",
"Received": "受信済み",
"Received amount": "受け取り額",
"Recent maintenance tasks running across instances and their execution status.": "各インスタンスで実行された最近のメンテナンスタスクとその実行状態。",
"Recently completed or failed system task runs.": "最近完了または失敗したシステムタスク実行です。",
"Recently launched models": "最近リリースされたモデル",
"Recently launched models gaining traction": "最近リリースされ勢いのあるモデル",
"Recharge": "チャージ",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "ルール JSON は配列である必要があります",
"Run GC": "GC 実行",
"Run tests for the selected models": "選択したモデルのテストを実行",
"running": "実行中",
"Running": "実行中",
"Runway": "残り期間",
"s": "s",
......@@ -3883,11 +3901,11 @@
"Shorten": "短縮",
"Show": "表示",
"Show All": "すべて表示",
"Show sensitive data": "機密データを表示",
"Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示",
"Show only bound providers": "バインド済みのプロバイダーのみ表示",
"Show or hide flow columns": "フロー列の表示・非表示",
"Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。",
"Show sensitive data": "機密データを表示",
"Show setup guide": "セットアップガイドを表示",
"Show token usage statistics in the UI": "UIでトークン使用統計を表示",
"Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "サブスクリプションを購入しました",
"Subscriptions": "サブスクリプション",
"Subtract": "減算",
"succeeded": "成功",
"Success": "成功",
"Success rate": "成功率",
"Successfully created {{count}} API Key(s)": "{{count}}個のAPIキーが正常に作成されました",
......@@ -4078,6 +4097,7 @@
"System Behavior": "システムの動作",
"System data statistics": "システムデータ統計",
"System default": "システムデフォルト",
"System Info": "システム情報",
"System Information": "システム情報",
"System initialized successfully! Redirecting…": "システムが正常に初期化されました!リダイレクト中…",
"System logo": "システムロゴ",
......@@ -4096,6 +4116,7 @@
"System Settings": "システム設定",
"System setup wizard": "システムセットアップウィザード",
"System task records": "システムタスク記録",
"System Tasks": "システムタスク",
"System Version": "システムバージョン",
"Table view": "テーブル表示",
"Tag": "タグ",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "ターゲットパス(任意)",
"Target User": "対象ユーザー",
"Task": "タスク",
"Task History": "タスク履歴",
"Task ID": "タスクID",
"Task ID:": "タスクID:",
"Task logs": "タスクログ",
"Task Logs": "タスク履歴",
"Task Logs": "タスクログ",
"Tasks currently pending or running.": "現在待機中または実行中のタスクです。",
"Team Collaboration": "チームコラボレーション",
"Technical Support": "テクニカルサポート",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "アップストリーム",
"Upstream did not return reset credit details.": "上流からリセット回数の詳細が返されませんでした。",
"Upstream Model Detection Settings": "アップストリームモデル検出設定",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上流モデル検出タスクを開始しました。システム情報で進捗を確認し、完了後に更新してステージングされた変更をご確認ください。",
"Upstream Model Update Check": "アップストリームモデル更新チェック",
"Upstream Model Updates": "上流モデルの更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "上流モデル更新を処理しました:{{added}} 個追加、{{removed}} 個削除、今回 {{ignored}} 個無視、合計 {{totalIgnored}} 個の無視モデル",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "警告: この操作は永続的で元に戻せません!",
"We apologize for the inconvenience.": "ご不便をおかけして申し訳ありません。",
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
"We could not load system tasks.": "システムタスクを読み込めませんでした。",
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
"Web search": "ウェブ検索",
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "Активных кэшей",
"Active Files": "Активных файлов",
"Active models": "Активные модели",
"Active Tasks": "Активные задачи",
"active users": "активных пользователей",
"Actual Amount": "Фактическая сумма",
"Actual Model": "Фактическая модель",
......@@ -428,6 +429,7 @@
"Ask anything": "Спросите что угодно",
"Assigned by administrator only": "Назначается только администратором",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Назначается администраторами и обозначает уровень пользователя, например default или vip.",
"Async task polling": "Опрос асинхронных задач",
"Async task refund": "Возврат асинхронной задачи",
"At least one model regex pattern is required": "Требуется хотя бы один шаблон регулярного выражения модели",
"At least one valid key source is required": "Требуется хотя бы один действительный источник ключа",
......@@ -483,6 +485,7 @@
"Auto-discover": "Автообнаружение",
"Auto-discovers endpoints from the provider": "Автоматически обнаруживает конечные точки от провайдера",
"Auto-fill when one field exists and another is missing": "Автозаполнение, когда одно поле есть, а другое отсутствует",
"Auto-refreshing every {{seconds}}s": "Автообновление каждые {{seconds}} с",
"Auto-retry status codes": "Коды авто-повтора",
"Automatically disable channel on repeated failures": "Автоматически отключать канал при повторных неудачах",
"Automatically disable channels exceeding this response time": "Автоматически отключать каналы, превышающие это время ответа",
......@@ -553,6 +556,7 @@
"Basic Information": "Основная информация",
"Basic Templates": "Базовые шаблоны",
"Batch Add (one key per line)": "Пакетное добавление (один ключ на строку)",
"Batch channel test": "Пакетное тестирование каналов",
"Batch delete failed": "Пакетное удаление не удалось",
"Batch deleted {{count}} channels": "Пакетно удалено каналов: {{count}}",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Пакетное обнаружение завершено: {{channels}} каналов, {{add}} для добавления, {{remove}} для удаления, {{fails}} ошибок",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "Пакетный тест завершен: {{success}} успешно, {{failed}} с ошибкой",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "Пакетный тест остановлен: {{completed}}/{{total}} завершено, {{success}} успешно, {{failed}} с ошибкой",
"Batch testing models...": "Пакетное тестирование моделей...",
"Batch upstream model update": "Пакетное обновление вышестоящих моделей",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Пакетное обновление моделей: {{channels}} каналов, {{added}} добавлено, {{removed}} удалено, {{fails}} ошибок",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "Лучший вариант для однопользовательских развёртываний. Опции ценообразования и биллинга будут скрыты.",
"Best TTFT": "Лучший TTFT",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "Разработано и создано",
"designed for scale": "спроектировано для масштабирования",
"Destroyed": "Уничтожено",
"Detail": "Подробности",
"Detailed request logs for investigations.": "Подробные журналы запросов для расследований.",
"Details": "Детали",
"Detect All Upstream Updates": "Обнаружить все обновления из upstream",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "Требуется курс обмена",
"Exchange rate must be greater than 0": "Курс обмена должен быть больше 0",
"Execute code in a sandbox during the response": "Выполнять код в песочнице во время ответа",
"Executor": "Исполнитель",
"Exhausted": "Исчерпано",
"Existing account will be reused": "Существующая учётная запись будет использована повторно",
"Existing Models ({{count}})": "Существующие модели ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "доп. пункты",
"Fail Reason": "Причина сбоя",
"Fail Reason Details": "Детали причины сбоя",
"failed": "ошибка",
"Failed": "Неудача",
"Failed to {{action}} user": "Не удалось выполнить {{action}} для пользователя",
"Failed to adjust quota": "Не удалось изменить квоту",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Список моделей, поддерживаемых этим каналом. Используйте запятую для разделения нескольких моделей.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Список источников (один на строку), разрешенных для регистрации и аутентификации Passkey.",
"List view": "Вид списка",
"Live refresh pauses when no task is running": "Автообновление приостанавливается, когда нет выполняемых задач",
"LLM Leaderboard": "Рейтинг LLM",
"LLM prompt helper": "Помощник с промптом для LLM",
"Load Balancing": "Балансировка нагрузки",
......@@ -2356,6 +2365,7 @@
"Locations": "Местоположения",
"Locked": "Заблокировано",
"log": "записи",
"Log cleanup": "Очистка журналов",
"Log cleanup progress": "Ход очистки журнала",
"Log cleanup task started.": "Задача очистки журнала запущена.",
"Log Details": "Детали журнала",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "Объединить в «Другое»",
"Message Priority": "Приоритет сообщения",
"Metadata": "Метаданные",
"Midjourney task polling": "Опрос задач Midjourney",
"min downtime": "мин простоя",
"Min Top-up": "Мин. пополнение",
"Min Top-up:": "Мин. пополнение:",
......@@ -2653,6 +2664,7 @@
"No": "Нет",
"No About Content Set": "Содержимое раздела \"О нас\" не установлено",
"No Active": "Нет активных",
"No active system tasks.": "Нет активных системных задач.",
"No additional type-specific settings for this channel type.": "Нет дополнительных настроек, специфичных для этого типа канала.",
"No amount options configured. Add amounts below to get started.": "Не настроены параметры суммы. Добавьте суммы ниже, чтобы начать.",
"No announcements at this time": "Нет объявлений на данный момент",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "Нет групп, соответствующих вашему поиску",
"No groups yet. Add a group to get started.": "Групп пока нет. Добавьте группу, чтобы начать.",
"No header overrides configured.": "Нет настроенных переопределений заголовков.",
"No historical system tasks.": "Нет исторических системных задач.",
"No history data available": "Исторические данные недоступны",
"No incidents in the last 24 hours": "За последние 24 часа инцидентов не было",
"No incidents in the last 30 days": "За последние 30 дней инцидентов не было",
......@@ -2787,6 +2800,7 @@
"No subscription records": "Нет записей подписок",
"No Sync": "Без синхронизации",
"No system announcements": "Нет системных объявлений",
"No system tasks yet.": "Пока нет системных задач.",
"No token found.": "Токен не найден.",
"No tools configured": "Нет настроенных инструментов",
"No Upgrade": "Без повышения",
......@@ -3083,6 +3097,7 @@
"Peak": "Пик",
"Peak throughput": "Пиковая пропускная способность",
"Penalises repetition of frequent tokens": "Штрафует повторение частых токенов",
"pending": "ожидание",
"Pending": "Ожидает",
"per": "за",
"Per 1K tokens": "За 1K токенов",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей",
"Received": "Получено",
"Received amount": "Полученная сумма",
"Recent maintenance tasks running across instances and their execution status.": "Недавние задачи обслуживания, выполняемые на всех экземплярах, и их статус выполнения.",
"Recently completed or failed system task runs.": "Недавние запуски системных задач, завершенные или завершившиеся с ошибкой.",
"Recently launched models": "Недавно запущенные модели",
"Recently launched models gaining traction": "Недавно вышедшие модели, набирающие популярность",
"Recharge": "Пополнение",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "JSON правил должен быть массивом",
"Run GC": "Запустить GC",
"Run tests for the selected models": "Запустить тесты для выбранных моделей",
"running": "выполняется",
"Running": "Выполняется",
"Runway": "Запас",
"s": "s",
......@@ -3883,11 +3901,11 @@
"Shorten": "Сократить",
"Show": "Показать",
"Show All": "Показать все",
"Show sensitive data": "Показать конфиденциальные данные",
"Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)",
"Show only bound providers": "Показать только привязанных провайдеров",
"Show or hide flow columns": "Показать или скрыть столбцы потока",
"Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.",
"Show sensitive data": "Показать конфиденциальные данные",
"Show setup guide": "Показать руководство по настройке",
"Show token usage statistics in the UI": "Показывать статистику использования токенов в пользовательском интерфейсе",
"Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "Подписка успешно приобретена",
"Subscriptions": "Подписки",
"Subtract": "Вычесть",
"succeeded": "успешно",
"Success": "Успешно",
"Success rate": "Доля успешных запросов",
"Successfully created {{count}} API Key(s)": "Успешно создано {{count}} API-ключ(а/ей)",
......@@ -4078,6 +4097,7 @@
"System Behavior": "Поведение системы",
"System data statistics": "Статистика системных данных",
"System default": "По умолчанию",
"System Info": "Информация о системе",
"System Information": "Системная информация",
"System initialized successfully! Redirecting…": "Система успешно инициализирована! Перенаправление…",
"System logo": "Логотип системы",
......@@ -4096,6 +4116,7 @@
"System Settings": "Настройки системы",
"System setup wizard": "Мастер настройки системы",
"System task records": "Записи системных задач",
"System Tasks": "Системные задачи",
"System Version": "Версия системы",
"Table view": "Вид таблицы",
"Tag": "Тег",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "Целевой путь (необязательно)",
"Target User": "Целевой пользователь",
"Task": "Задача",
"Task History": "История задач",
"Task ID": "ID задачи",
"Task ID:": "ID задачи:",
"Task logs": "Журналы задач",
"Task Logs": "Журнал задач",
"Tasks currently pending or running.": "Задачи, которые ожидают выполнения или выполняются сейчас.",
"Team Collaboration": "Совместная работа в команде",
"Technical Support": "Техническая поддержка",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "Источник",
"Upstream did not return reset credit details.": "Вышестоящий сервис не вернул сведения о сбросах лимита.",
"Upstream Model Detection Settings": "Настройки обнаружения моделей провайдера",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Задача обнаружения моделей вышестоящего источника запущена. Следите за ходом в разделе «Информация о системе», затем обновите, чтобы просмотреть подготовленные изменения.",
"Upstream Model Update Check": "Проверка обновлений моделей провайдера",
"Upstream Model Updates": "Обновления моделей источника",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Обновления моделей применены: {{added}} добавлено, {{removed}} удалено, {{ignored}} проигнорировано, всего {{totalIgnored}} проигнорированных моделей",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "Внимание: Это действие является постоянным и необратимым!",
"We apologize for the inconvenience.": "Приносим извинения за неудобства.",
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
"We could not load system tasks.": "Не удалось загрузить системные задачи.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
"Web search": "Веб-поиск",
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "Số bộ nhớ đệm hoạt động",
"Active Files": "Tệp đang hoạt động",
"Active models": "Mô hình đang hoạt động",
"Active Tasks": "Tác vụ đang hoạt động",
"active users": "Người dùng tích cực",
"Actual Amount": "Số tiền thực tế",
"Actual Model": "Mô hình thực tế",
......@@ -428,6 +429,7 @@
"Ask anything": "Hỏi gì cũng được",
"Assigned by administrator only": "Chỉ quản trị viên gán",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Do quản trị viên gán và dùng để biểu thị cấp người dùng, ví dụ default hoặc vip.",
"Async task polling": "Thăm dò tác vụ bất đồng bộ",
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
"At least one model regex pattern is required": "Cần ít nhất một mẫu regex mô hình",
"At least one valid key source is required": "Cần ít nhất một nguồn khóa hợp lệ",
......@@ -483,6 +485,7 @@
"Auto-discover": "Tự động khám phá",
"Auto-discovers endpoints from the provider": "Tự động khám phá các điểm cuối từ nhà cung cấp",
"Auto-fill when one field exists and another is missing": "Tự động điền khi một trường có giá trị và trường khác thiếu",
"Auto-refreshing every {{seconds}}s": "Tự động làm mới mỗi {{seconds}} giây",
"Auto-retry status codes": "Mã trạng thái tự thử lại",
"Automatically disable channel on repeated failures": "Tự động vô hiệu hóa kênh khi xảy ra lỗi lặp lại",
"Automatically disable channels exceeding this response time": "Tự động vô hiệu hóa các kênh vượt quá thời gian phản hồi này",
......@@ -553,6 +556,7 @@
"Basic Information": "Thông tin cơ bản",
"Basic Templates": "Mẫu cơ bản",
"Batch Add (one key per line)": "Thêm hàng loạt (mỗi khóa một dòng)",
"Batch channel test": "Kiểm tra kênh hàng loạt",
"Batch delete failed": "Xóa hàng loạt thất bại",
"Batch deleted {{count}} channels": "Đã xóa hàng loạt {{count}} kênh",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Phát hiện hàng loạt hoàn tất: {{channels}} kênh, {{add}} để thêm, {{remove}} để xóa, {{fails}} thất bại",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "Kiểm thử hàng loạt hoàn tất: {{success}} thành công, {{failed}} thất bại",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "Đã dừng kiểm thử hàng loạt: hoàn tất {{completed}}/{{total}}, {{success}} thành công, {{failed}} thất bại",
"Batch testing models...": "Đang kiểm thử mô hình hàng loạt...",
"Batch upstream model update": "Cập nhật mô hình thượng nguồn hàng loạt",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Đã áp dụng cập nhật hàng loạt mô hình upstream: {{channels}} kênh, {{added}} đã thêm, {{removed}} đã xóa, {{fails}} thất bại",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "Phù hợp nhất cho triển khai đơn người dùng. Các tùy chọn giá và thanh toán sẽ được ẩn.",
"Best TTFT": "TTFT tốt nhất",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "Thiết kế và Phát triển bởi",
"designed for scale": "thiết kế cho quy mô lớn",
"Destroyed": "Đã hủy",
"Detail": "Chi tiết",
"Detailed request logs for investigations.": "Nhật ký yêu cầu chi tiết cho các cuộc điều tra.",
"Details": "Chi tiết",
"Detect All Upstream Updates": "Phát hiện Tất cả Cập nhật Upstream",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "Cần có tỷ giá",
"Exchange rate must be greater than 0": "Tỷ giá phải lớn hơn 0",
"Execute code in a sandbox during the response": "Thực thi mã trong sandbox trong quá trình phản hồi",
"Executor": "Trình thực thi",
"Exhausted": "Đã cạn kiệt",
"Existing account will be reused": "Tài khoản hiện có sẽ được sử dụng lại",
"Existing Models ({{count}})": "Các mô hình hiện có ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "mục bổ sung",
"Fail Reason": "Lý do thất bại",
"Fail Reason Details": "Chi tiết lý do thất bại",
"failed": "thất bại",
"Failed": "Thất bại",
"Failed to {{action}} user": "Không thể {{action}} người dùng",
"Failed to adjust quota": "Không thể điều chỉnh hạn mức",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Danh sách các mô hình được hỗ trợ bởi kênh này. Sử dụng dấu phẩy để phân tách nhiều mô hình.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Danh sách các nguồn gốc (mỗi dòng một mục) được phép đăng ký và xác thực Passkey.",
"List view": "Xem dạng danh sách",
"Live refresh pauses when no task is running": "Tự động làm mới tạm dừng khi không có tác vụ nào đang chạy",
"LLM Leaderboard": "Bảng xếp hạng LLM",
"LLM prompt helper": "Trợ lý prompt LLM",
"Load Balancing": "Tải cân bằng",
......@@ -2356,6 +2365,7 @@
"Locations": "Vị trí",
"Locked": "Đã khóa",
"log": "nhật ký",
"Log cleanup": "Dọn dẹp nhật ký",
"Log cleanup progress": "Tiến trình dọn dẹp nhật ký",
"Log cleanup task started.": "Đã bắt đầu tác vụ dọn dẹp nhật ký.",
"Log Details": "Chi tiết Nhật ký",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "Gộp vào Khác",
"Message Priority": "Ưu tiên tin nhắn",
"Metadata": "Siêu dữ liệu",
"Midjourney task polling": "Thăm dò tác vụ Midjourney",
"min downtime": "phút gián đoạn",
"Min Top-up": "Nạp tối thiểu",
"Min Top-up:": "Nạp tối thiểu:",
......@@ -2653,6 +2664,7 @@
"No": "Không",
"No About Content Set": "Chưa đặt nội dung Giới thiệu",
"No Active": "Không hoạt động",
"No active system tasks.": "Không có tác vụ hệ thống đang hoạt động.",
"No additional type-specific settings for this channel type.": "Không có cài đặt bổ sung cụ thể theo loại cho loại kênh này.",
"No amount options configured. Add amounts below to get started.": "Chưa có tùy chọn số tiền nào được cấu hình. Thêm các số tiền bên dưới để bắt đầu.",
"No announcements at this time": "Hiện tại chưa có thông báo nào.",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "Không có nhóm nào khớp với tìm kiếm của bạn",
"No groups yet. Add a group to get started.": "Chưa có nhóm nào. Thêm một nhóm để bắt đầu.",
"No header overrides configured.": "Không có ghi đè tiêu đề nào được cấu hình.",
"No historical system tasks.": "Không có tác vụ hệ thống trong lịch sử.",
"No history data available": "Không có dữ liệu lịch sử",
"No incidents in the last 24 hours": "Không có sự cố trong 24 giờ qua",
"No incidents in the last 30 days": "Không có sự cố trong 30 ngày qua",
......@@ -2787,6 +2800,7 @@
"No subscription records": "Không có bản ghi đăng ký",
"No Sync": "Không đồng bộ",
"No system announcements": "Không có thông báo hệ thống",
"No system tasks yet.": "Chưa có tác vụ hệ thống nào.",
"No token found.": "Không tìm thấy mã thông báo.",
"No tools configured": "Chưa cấu hình công cụ nào",
"No Upgrade": "Không nâng cấp",
......@@ -3083,6 +3097,7 @@
"Peak": "Đỉnh",
"Peak throughput": "Thông lượng đỉnh",
"Penalises repetition of frequent tokens": "Phạt việc lặp các token phổ biến",
"pending": "đang chờ",
"Pending": "Đang chờ",
"per": "per",
"Per 1K tokens": "Mỗi 1K tokens",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn",
"Received": "Đã nhận",
"Received amount": "Số tiền đã nhận",
"Recent maintenance tasks running across instances and their execution status.": "Các tác vụ bảo trì gần đây chạy trên các phiên bản và trạng thái thực thi của chúng.",
"Recently completed or failed system task runs.": "Các lần chạy tác vụ hệ thống gần đây đã hoàn tất hoặc thất bại.",
"Recently launched models": "Các mô hình ra mắt gần đây",
"Recently launched models gaining traction": "Mô hình mới phát hành đang được ưa chuộng",
"Recharge": "Nạp lại",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "JSON quy tắc phải là một mảng",
"Run GC": "Chạy GC",
"Run tests for the selected models": "Chạy kiểm thử cho các mô hình đã chọn",
"running": "đang chạy",
"Running": "Đang chạy",
"Runway": "Thời gian còn lại",
"s": "s",
......@@ -3883,11 +3901,11 @@
"Shorten": "Rút gọn",
"Show": "Hiển thị",
"Show All": "Hiển thị tất cả",
"Show sensitive data": "Hiển thị dữ liệu nhạy cảm",
"Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)",
"Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết",
"Show or hide flow columns": "Hiện hoặc ẩn các cột luồng",
"Show prices in currency instead of quota.": "Hiển thị giá bằng tiền tệ thay vì hạn ngạch.",
"Show sensitive data": "Hiển thị dữ liệu nhạy cảm",
"Show setup guide": "Hiển thị hướng dẫn thiết lập",
"Show token usage statistics in the UI": "Hiển thị thống kê sử dụng token trong giao diện người dùng",
"Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
"Subscriptions": "Đăng ký",
"Subtract": "Trừ",
"succeeded": "thành công",
"Success": "Thành công",
"Success rate": "Tỷ lệ thành công",
"Successfully created {{count}} API Key(s)": "Đã tạo thành công {{count}} khóa API",
......@@ -4078,6 +4097,7 @@
"System Behavior": "Hành vi hệ thống",
"System data statistics": "Thống kê dữ liệu hệ thống",
"System default": "Mặc định hệ thống",
"System Info": "Thông tin hệ thống",
"System Information": "Thông tin hệ thống",
"System initialized successfully! Redirecting…": "Hệ thống đã được khởi tạo thành công! Đang chuyển hướng…",
"System logo": "Logo hệ thống",
......@@ -4096,6 +4116,7 @@
"System Settings": "Cài đặt hệ thống",
"System setup wizard": "Trình hướng dẫn thiết lập hệ thống",
"System task records": "Lịch sử tác vụ hệ thống",
"System Tasks": "Tác vụ hệ thống",
"System Version": "Phiên bản hệ thống",
"Table view": "Xem dạng bảng",
"Tag": "Tag",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "Đường dẫn đích (tùy chọn)",
"Target User": "Người dùng mục tiêu",
"Task": "Nhiệm vụ",
"Task History": "Lịch sử tác vụ",
"Task ID": "Mã nhiệm vụ",
"Task ID:": "ID nhiệm vụ:",
"Task logs": "Nhật ký tác vụ",
"Task Logs": "Nhật ký tác vụ",
"Tasks currently pending or running.": "Các tác vụ hiện đang chờ hoặc đang chạy.",
"Team Collaboration": "Teamwork",
"Technical Support": "Hỗ trợ kỹ thuật",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "Thượng nguồn",
"Upstream did not return reset credit details.": "Upstream không trả về chi tiết lượt đặt lại.",
"Upstream Model Detection Settings": "Cài đặt phát hiện mô hình nguồn",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Đã bắt đầu tác vụ phát hiện mô hình thượng nguồn. Theo dõi tiến trình trong Thông tin hệ thống, sau đó làm mới để xem các cập nhật đang chờ.",
"Upstream Model Update Check": "Kiểm tra cập nhật mô hình nguồn",
"Upstream Model Updates": "Cập nhật mô hình upstream",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Đã áp dụng cập nhật mô hình upstream: {{added}} đã thêm, {{removed}} đã xóa, {{ignored}} bỏ qua lần này, {{totalIgnored}} tổng mô hình đã bỏ qua",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "Cảnh báo: Hành động này là vĩnh viễn và không thể đảo ngược!",
"We apologize for the inconvenience.": "Chúng tôi xin lỗi vì sự bất tiện này.",
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
"We could not load system tasks.": "Không thể tải tác vụ hệ thống.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
"Web search": "Tìm kiếm web",
......
......@@ -137,6 +137,7 @@
"Active Cache Count": "活跃缓存数",
"Active Files": "活跃文件",
"Active models": "活跃模型",
"Active Tasks": "进行中任务",
"active users": "活跃用户",
"Actual Amount": "实付金额",
"Actual Model": "实际模型",
......@@ -428,6 +429,7 @@
"Ask anything": "随便问",
"Assigned by administrator only": "仅管理员分配",
"Assigned by administrators and used to represent a user level, such as default or vip.": "由管理员分配,用于表示用户等级,例如 default 或 vip。",
"Async task polling": "异步任务轮询",
"Async task refund": "异步任务退款",
"At least one model regex pattern is required": "至少需要一个模型正则匹配模式",
"At least one valid key source is required": "至少需要一个有效的密钥来源",
......@@ -483,6 +485,7 @@
"Auto-discover": "自动发现",
"Auto-discovers endpoints from the provider": "自动从提供商发现端点",
"Auto-fill when one field exists and another is missing": "在一个字段有值、另一个缺失时自动补齐",
"Auto-refreshing every {{seconds}}s": "每 {{seconds}} 秒自动刷新",
"Auto-retry status codes": "自动重试状态码",
"Automatically disable channel on repeated failures": "重复失败时自动禁用渠道",
"Automatically disable channels exceeding this response time": "自动禁用超出此响应时间的渠道",
......@@ -553,6 +556,7 @@
"Basic Information": "基本信息",
"Basic Templates": "基础模板",
"Batch Add (one key per line)": "批量添加(每行一个密钥)",
"Batch channel test": "渠道批量测试",
"Batch delete failed": "批量删除失败",
"Batch deleted {{count}} channels": "批量删除 {{count}} 个渠道",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个",
......@@ -568,6 +572,7 @@
"Batch test completed: {{success}} succeeded, {{failed}} failed": "批量测试完成:{{success}} 个成功,{{failed}} 个失败",
"Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed": "批量测试已停止:已完成 {{completed}}/{{total}},{{success}} 个成功,{{failed}} 个失败",
"Batch testing models...": "正在批量测试模型...",
"Batch upstream model update": "上游模型批量更新",
"Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "已批量处理上游模型更新:渠道 {{channels}} 个,加入 {{added}} 个,删除 {{removed}} 个,失败 {{fails}} 个",
"Best for single-tenant deployments. Pricing and billing options stay hidden.": "适合单用户部署。定价和计费选项将被隐藏。",
"Best TTFT": "最优 TTFT",
......@@ -1268,6 +1273,7 @@
"Designed and Developed by": "设计与开发",
"designed for scale": "为规模而设计",
"Destroyed": "已销毁",
"Detail": "详情",
"Detailed request logs for investigations.": "用于调查的详细请求日志。",
"Details": "详情",
"Detect All Upstream Updates": "检测所有上游更新",
......@@ -1635,6 +1641,7 @@
"Exchange rate is required": "汇率为必填项",
"Exchange rate must be greater than 0": "汇率必须大于 0",
"Execute code in a sandbox during the response": "在响应过程中沙箱执行代码",
"Executor": "执行实例",
"Exhausted": "已耗尽",
"Existing account will be reused": "将使用现有账户",
"Existing Models ({{count}})": "现有模型 ({{count}})",
......@@ -1674,6 +1681,7 @@
"extras": "额外项",
"Fail Reason": "失败原因",
"Fail Reason Details": "失败原因详情",
"failed": "已失败",
"Failed": "失败",
"Failed to {{action}} user": "{{action}}用户失败",
"Failed to adjust quota": "调整额度失败",
......@@ -2335,6 +2343,7 @@
"List of models supported by this channel. Use comma to separate multiple models.": "此渠道支持的模型列表。使用逗号分隔多个模型。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "允许用于 Passkey 注册和身份验证的来源列表(每行一个)。",
"List view": "列表视图",
"Live refresh pauses when no task is running": "无任务运行时暂停自动刷新",
"LLM Leaderboard": "LLM 排行榜",
"LLM prompt helper": "LLM 辅助设计提示词",
"Load Balancing": "负载均衡",
......@@ -2356,6 +2365,7 @@
"Locations": "位置",
"Locked": "锁定",
"log": "日志的完整详情",
"Log cleanup": "日志清理",
"Log cleanup progress": "日志清理进度",
"Log cleanup task started.": "日志清理任务已启动。",
"Log Details": "日志详情",
......@@ -2446,6 +2456,7 @@
"Merge into Other": "合并为其他",
"Message Priority": "消息优先级",
"Metadata": "元信息",
"Midjourney task polling": "Midjourney 任务轮询",
"min downtime": "分钟停机",
"Min Top-up": "最低充值",
"Min Top-up:": "最低充值:",
......@@ -2653,6 +2664,7 @@
"No": "否",
"No About Content Set": "未设置关于内容",
"No Active": "无生效",
"No active system tasks.": "暂无进行中的系统任务。",
"No additional type-specific settings for this channel type.": "此渠道类型没有额外的特定类型设置。",
"No amount options configured. Add amounts below to get started.": "未配置金额选项。在下方添加金额即可开始使用。",
"No announcements at this time": "目前暂无公告",
......@@ -2708,6 +2720,7 @@
"No groups match your search": "没有组匹配您的搜索",
"No groups yet. Add a group to get started.": "暂无分组,添加一个分组开始配置。",
"No header overrides configured.": "未配置标头覆盖。",
"No historical system tasks.": "暂无历史系统任务。",
"No history data available": "暂无历史数据",
"No incidents in the last 24 hours": "最近 24 小时无异常",
"No incidents in the last 30 days": "最近 30 天无事件",
......@@ -2787,6 +2800,7 @@
"No subscription records": "暂无订阅记录",
"No Sync": "不同步",
"No system announcements": "暂无系统公告",
"No system tasks yet.": "暂无系统任务。",
"No token found.": "未找到令牌。",
"No tools configured": "未配置工具",
"No Upgrade": "不升级",
......@@ -3083,6 +3097,7 @@
"Peak": "峰值",
"Peak throughput": "峰值吞吐",
"Penalises repetition of frequent tokens": "惩罚高频 token 的重复出现",
"pending": "等待中",
"Pending": "待确认",
"per": "每",
"Per 1K tokens": "每 1K tokens",
......@@ -3392,6 +3407,8 @@
"Receive Upstream Model Update Notifications": "接收上游模型更新通知",
"Received": "获得",
"Received amount": "已收额度",
"Recent maintenance tasks running across instances and their execution status.": "跨实例运行的近期维护任务及其执行状态。",
"Recently completed or failed system task runs.": "最近已完成或失败的系统任务运行记录。",
"Recently launched models": "近期发布的模型",
"Recently launched models gaining traction": "近期发布并快速增长的模型",
"Recharge": "充值",
......@@ -3649,6 +3666,7 @@
"Rules JSON must be an array": "规则 JSON 必须是数组",
"Run GC": "执行 GC",
"Run tests for the selected models": "运行所选模型的测试",
"running": "运行中",
"Running": "运行中",
"Runway": "可用时长",
"s": "秒",
......@@ -3883,11 +3901,11 @@
"Shorten": "缩词",
"Show": "显示",
"Show All": "显示全部",
"Show sensitive data": "显示敏感数据",
"Show all providers including unbound": "显示所有提供商(包括未绑定)",
"Show only bound providers": "仅显示已绑定的提供商",
"Show or hide flow columns": "显示或隐藏分流列",
"Show prices in currency instead of quota.": "以货币而非配额显示价格。",
"Show sensitive data": "显示敏感数据",
"Show setup guide": "显示设置引导",
"Show token usage statistics in the UI": "在用户界面中显示令牌使用统计信息",
"Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。",
......@@ -4029,6 +4047,7 @@
"Subscription purchased successfully": "订阅购买成功",
"Subscriptions": "订阅",
"Subtract": "减少",
"succeeded": "已成功",
"Success": "成功",
"Success rate": "成功率",
"Successfully created {{count}} API Key(s)": "成功创建了 {{count}} 个 API 密钥",
......@@ -4078,6 +4097,7 @@
"System Behavior": "系统行为",
"System data statistics": "系统数据统计",
"System default": "系统默认",
"System Info": "系统信息",
"System Information": "系统信息",
"System initialized successfully! Redirecting…": "系统初始化成功!正在重定向…",
"System logo": "系统徽标",
......@@ -4096,6 +4116,7 @@
"System Settings": "系统设置",
"System setup wizard": "系统设置向导",
"System task records": "系统任务记录",
"System Tasks": "系统任务",
"System Version": "系统版本",
"Table view": "表格视图",
"Tag": "标签",
......@@ -4116,10 +4137,12 @@
"Target Path (optional)": "目标路径(可选)",
"Target User": "目标用户",
"Task": "任务",
"Task History": "历史任务",
"Task ID": "任务 ID",
"Task ID:": "任务 ID:",
"Task logs": "任务日志",
"Task Logs": "任务日志",
"Tasks currently pending or running.": "当前等待中或运行中的任务。",
"Team Collaboration": "团队协作",
"Technical Support": "技术支持",
"Telegram": "Telegram",
......@@ -4498,6 +4521,7 @@
"Upstream": "上游",
"Upstream did not return reset credit details.": "上游未返回重置次数详情。",
"Upstream Model Detection Settings": "检测上游模型设置",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型检测任务已开始。可在「系统信息」中查看进度,完成后刷新以查看待处理的更新。",
"Upstream Model Update Check": "上游模型更新检查",
"Upstream Model Updates": "上游模型更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个",
......@@ -4711,6 +4735,7 @@
"Warning: This action is permanent and irreversible!": "警告:此操作是永久且不可逆的!",
"We apologize for the inconvenience.": "对于由此造成的不便,我们深表歉意。",
"We could not load the setup status.": "我们无法加载设置状态。",
"We could not load system tasks.": "无法加载系统任务。",
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
"We'll be back online shortly.": "我们将很快恢复在线。",
"Web search": "网络搜索",
......
......@@ -40,6 +40,7 @@ import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenti
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
import { Route as AuthenticatedSystemInfoIndexRouteImport } from './routes/_authenticated/system-info/index'
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
......@@ -226,6 +227,12 @@ const AuthenticatedSystemSettingsIndexRoute =
path: '/',
getParentRoute: () => AuthenticatedSystemSettingsRouteRoute,
} as any)
const AuthenticatedSystemInfoIndexRoute =
AuthenticatedSystemInfoIndexRouteImport.update({
id: '/system-info/',
path: '/system-info/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSubscriptionsIndexRoute =
AuthenticatedSubscriptionsIndexRouteImport.update({
id: '/subscriptions/',
......@@ -431,6 +438,7 @@ export interface FileRoutesByFullPath {
'/profile/': typeof AuthenticatedProfileIndexRoute
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
......@@ -489,6 +497,7 @@ export interface FileRoutesByTo {
'/profile': typeof AuthenticatedProfileIndexRoute
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
......@@ -551,6 +560,7 @@ export interface FileRoutesById {
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
......@@ -612,6 +622,7 @@ export interface FileRouteTypes {
| '/profile/'
| '/redemption-codes/'
| '/subscriptions/'
| '/system-info/'
| '/system-settings/'
| '/usage-logs/'
| '/users/'
......@@ -670,6 +681,7 @@ export interface FileRouteTypes {
| '/profile'
| '/redemption-codes'
| '/subscriptions'
| '/system-info'
| '/system-settings'
| '/usage-logs'
| '/users'
......@@ -731,6 +743,7 @@ export interface FileRouteTypes {
| '/_authenticated/profile/'
| '/_authenticated/redemption-codes/'
| '/_authenticated/subscriptions/'
| '/_authenticated/system-info/'
| '/_authenticated/system-settings/'
| '/_authenticated/usage-logs/'
| '/_authenticated/users/'
......@@ -992,6 +1005,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSystemSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSystemSettingsRouteRoute
}
'/_authenticated/system-info/': {
id: '/_authenticated/system-info/'
path: '/system-info'
fullPath: '/system-info/'
preLoaderRoute: typeof AuthenticatedSystemInfoIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/subscriptions/': {
id: '/_authenticated/subscriptions/'
path: '/subscriptions'
......@@ -1290,6 +1310,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedSystemInfoIndexRoute: typeof AuthenticatedSystemInfoIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
......@@ -1313,6 +1334,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedRedemptionCodesIndexRoute:
AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
AuthenticatedSystemInfoIndexRoute: AuthenticatedSystemInfoIndexRoute,
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { SystemInfo } from '@/features/system-info'
export const Route = createFileRoute('/_authenticated/system-info/')({
beforeLoad: () => {
const { auth } = useAuthStore.getState()
if (auth.user?.role !== ROLE.SUPER_ADMIN) {
throw redirect({
to: '/403',
})
}
},
component: SystemInfo,
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment