Commit 53771922 by Calcium-Ion Committed by GitHub

feat: add system task runner (#5680)

parent acb52d0f
...@@ -158,10 +158,21 @@ var RetryTimes = 0 ...@@ -158,10 +158,21 @@ var RetryTimes = 0
var IsMasterNode bool var IsMasterNode bool
// NodeName 节点名称,从 NODE_NAME 环境变量读取; const (
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。 NodeNameSourceManual = "manual"
NodeNameSourceHostname = "hostname"
)
// NodeName 节点名称,优先从 NODE_NAME 环境变量读取,未配置时回退主机名。
// 用于审计日志和后台任务中标识节点身份;多实例部署时建议显式配置稳定 NODE_NAME。
var NodeName = "" 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 int
var RequestInterval time.Duration var RequestInterval time.Duration
......
...@@ -82,9 +82,7 @@ func InitEnv() { ...@@ -82,9 +82,7 @@ func InitEnv() {
DebugEnabled = os.Getenv("DEBUG") == "true" DebugEnabled = os.Getenv("DEBUG") == "true"
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
IsMasterNode = os.Getenv("NODE_TYPE") != "slave" IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
// NodeName 优先用 NODE_NAME,未配置时回退主机名(容器下=容器 ID/Pod 名,自动扩容天然唯一)。 initNodeNameIdentity()
hostname, _ := os.Hostname()
NodeName = GetEnvOrDefaultString("NODE_NAME", hostname)
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false) TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
if TLSInsecureSkipVerify { if TLSInsecureSkipVerify {
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil { 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,
}
}
package controller package controller
import ( import (
"net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
...@@ -109,3 +110,21 @@ func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T ...@@ -109,3 +110,21 @@ func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T
require.Equal(t, 1, selected[0].Id) require.Equal(t, 1, selected[0].Id)
require.Equal(t, 2, selected[1].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 package controller
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
...@@ -52,16 +52,12 @@ var channelUpstreamModelUpdateSelectFields = []string{ ...@@ -52,16 +52,12 @@ var channelUpstreamModelUpdateSelectFields = []string{
"header_override", "header_override",
} }
var ( var channelUpstreamModelUpdateNotifyState = struct {
channelUpstreamModelUpdateTaskOnce sync.Once
channelUpstreamModelUpdateTaskRunning atomic.Bool
channelUpstreamModelUpdateNotifyState = struct {
sync.Mutex sync.Mutex
lastNotifiedAt int64 lastNotifiedAt int64
lastChangedChannels int lastChangedChannels int
lastFailedChannels int lastFailedChannels int
}{} }{}
)
type applyChannelUpstreamModelUpdatesRequest struct { type applyChannelUpstreamModelUpdatesRequest struct {
ID int `json:"id"` ID int `json:"id"`
...@@ -519,12 +515,24 @@ func buildUpstreamModelUpdateTaskNotificationContent( ...@@ -519,12 +515,24 @@ func buildUpstreamModelUpdateTaskNotificationContent(
return builder.String() return builder.String()
} }
func runChannelUpstreamModelUpdateTaskOnce() { type upstreamModelUpdateSummary struct {
if !channelUpstreamModelUpdateTaskRunning.CompareAndSwap(false, true) { CheckedChannels int `json:"checked_channels"`
return ChangedChannels int `json:"changed_channels"`
} DetectedAddModels int `json:"detected_add_models"`
defer channelUpstreamModelUpdateTaskRunning.Store(false) 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 checkedChannels := 0
failedChannels := 0 failedChannels := 0
failedChannelIDs := make([]int, 0) failedChannelIDs := make([]int, 0)
...@@ -537,8 +545,20 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -537,8 +545,20 @@ func runChannelUpstreamModelUpdateTaskOnce() {
removeModelSamples := make([]string, 0) removeModelSamples := make([]string, 0)
refreshNeeded := false 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 lastID := 0
scanLoop:
for { for {
if ctx != nil && ctx.Err() != nil {
break
}
var channels []*model.Channel var channels []*model.Channel
query := model.DB. query := model.DB.
Select(channelUpstreamModelUpdateSelectFields). Select(channelUpstreamModelUpdateSelectFields).
...@@ -562,6 +582,14 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -562,6 +582,14 @@ func runChannelUpstreamModelUpdateTaskOnce() {
if channel == nil { if channel == nil {
continue continue
} }
if ctx != nil && ctx.Err() != nil {
break scanLoop
}
processed++
if report != nil {
report(processed, int(totalChannels))
}
settings := channel.GetOtherSettings() settings := channel.GetOtherSettings()
if !settings.UpstreamModelUpdateCheckEnabled { if !settings.UpstreamModelUpdateCheckEnabled {
...@@ -569,7 +597,7 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -569,7 +597,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
} }
checkedChannels++ checkedChannels++
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, false, true) modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, force, allowAutoApply)
if err != nil { if err != nil {
failedChannels++ failedChannels++
failedChannelIDs = append(failedChannelIDs, channel.Id) failedChannelIDs = append(failedChannelIDs, channel.Id)
...@@ -598,7 +626,15 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -598,7 +626,15 @@ func runChannelUpstreamModelUpdateTaskOnce() {
autoAddedModels += autoAdded autoAddedModels += autoAdded
if common.RequestInterval > 0 { if common.RequestInterval > 0 {
if ctx == nil {
time.Sleep(common.RequestInterval) time.Sleep(common.RequestInterval)
} else {
select {
case <-ctx.Done():
break scanLoop
case <-time.After(common.RequestInterval):
}
}
} }
} }
...@@ -607,10 +643,23 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -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 { if refreshNeeded {
refreshChannelRuntimeCache() refreshChannelRuntimeCache()
} }
summary := upstreamModelUpdateSummary{
CheckedChannels: checkedChannels,
ChangedChannels: changedChannels,
DetectedAddModels: detectedAddModels,
DetectedRemoveModels: detectedRemoveModels,
FailedChannels: failedChannels,
AutoAddedModels: autoAddedModels,
}
if checkedChannels > 0 || common.DebugEnabled { if checkedChannels > 0 || common.DebugEnabled {
common.SysLog(fmt.Sprintf( 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", "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() { ...@@ -630,7 +679,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
changedChannels, changedChannels,
failedChannels, failedChannels,
)) ))
return return summary
} }
service.NotifyUpstreamModelUpdateWatchers( service.NotifyUpstreamModelUpdateWatchers(
"上游模型巡检通知", "上游模型巡检通知",
...@@ -647,37 +696,7 @@ func runChannelUpstreamModelUpdateTaskOnce() { ...@@ -647,37 +696,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
), ),
) )
} }
} return summary
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()
}
}()
})
} }
func ApplyChannelUpstreamModelUpdates(c *gin.Context) { func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
...@@ -931,75 +950,40 @@ func ApplyAllChannelUpstreamModelUpdates(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) { func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
results := make([]detectChannelUpstreamModelUpdatesResult, 0) task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true})
failed := make([]int, 0)
detectedAddCount := 0
detectedRemoveCount := 0
refreshNeeded := false
lastID := 0
for {
channels, err := findEnabledChannelsAfterID(lastID, channelUpstreamModelUpdateTaskBatchSize)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
if len(channels) == 0 { if !created {
break c.JSON(http.StatusConflict, gin.H{
} "success": false,
lastID = channels[len(channels)-1].Id "message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
for _, channel := range channels { "task_id": task.TaskID,
if channel == nil { "status": task.Status,
continue "type": task.Type,
} },
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,
}) })
return
} }
if len(channels) < channelUpstreamModelUpdateTaskBatchSize { recordManageAudit(c, "channel.upstream_detect_all", map[string]interface{}{
break "task_id": task.TaskID,
} })
}
if refreshNeeded {
refreshChannelRuntimeCache()
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": gin.H{ "data": gin.H{
"processed_channels": len(results), "task_id": task.TaskID,
"failed_channel_ids": failed, "status": task.Status,
"detected_add_models": detectedAddCount,
"detected_remove_models": detectedRemoveCount,
"channel_detected_results": results,
}, },
}) })
} }
package controller package controller
import ( import (
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -177,3 +180,21 @@ func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) { ...@@ -177,3 +180,21 @@ func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0)) require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90001, 0, 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 ...@@ -3,7 +3,6 @@ package controller
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
...@@ -20,16 +19,29 @@ import ( ...@@ -20,16 +19,29 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func UpdateMidjourneyTaskBulk() { // midjourneyPollSummary is the result recorded on a midjourney_poll system task
//imageModel := "midjourney" // row, summarizing one polling pass.
ctx := context.TODO() type midjourneyPollSummary struct {
for { UnfinishedTasks int `json:"unfinished_tasks"`
time.Sleep(time.Duration(15) * time.Second) 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() tasks := model.GetAllUnFinishTasks()
if len(tasks) == 0 { if len(tasks) == 0 {
continue return summary
} }
summary.UnfinishedTasks = len(tasks)
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks))) logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string) taskChannelM := make(map[int][]string)
...@@ -45,6 +57,7 @@ func UpdateMidjourneyTaskBulk() { ...@@ -45,6 +57,7 @@ func UpdateMidjourneyTaskBulk() {
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId) taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
} }
if len(nullTaskIds) > 0 { if len(nullTaskIds) > 0 {
summary.NullTasksFailed = len(nullTaskIds)
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{ err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
"status": "FAILURE", "status": "FAILURE",
"progress": "100%", "progress": "100%",
...@@ -56,10 +69,20 @@ func UpdateMidjourneyTaskBulk() { ...@@ -56,10 +69,20 @@ func UpdateMidjourneyTaskBulk() {
} }
} }
if len(taskChannelM) == 0 { if len(taskChannelM) == 0 {
continue return summary
} }
totalChannels := len(taskChannelM)
processedChannels := 0
for channelId, taskIds := range taskChannelM { 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))) logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 { if len(taskIds) == 0 {
continue continue
...@@ -79,39 +102,48 @@ func UpdateMidjourneyTaskBulk() { ...@@ -79,39 +102,48 @@ func UpdateMidjourneyTaskBulk() {
} }
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL) requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
body, _ := json.Marshal(map[string]any{ body, err := common.Marshal(map[string]any{
"ids": taskIds, "ids": taskIds,
}) })
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err)) logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
continue continue
} }
// 设置超时时间
timeout := time.Second * 15 timeout := time.Second * 15
ctx, cancel := context.WithTimeout(context.Background(), timeout) requestCtx, cancel := context.WithTimeout(ctx, timeout)
// 使用带有超时的 context 创建新的请求 req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
req = req.WithContext(ctx) 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("Content-Type", "application/json")
req.Header.Set("mj-api-secret", midjourneyChannel.Key) req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req) resp, err := service.GetHttpClient().Do(req)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err)) logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
cancel()
continue continue
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
resp.Body.Close()
cancel()
continue continue
} }
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err)) logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
resp.Body.Close()
cancel()
continue continue
} }
var responseItems []dto.MidjourneyDto var responseItems []dto.MidjourneyDto
err = json.Unmarshal(responseBody, &responseItems) err = common.Unmarshal(responseBody, &responseItems)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody))) logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
resp.Body.Close()
cancel()
continue continue
} }
resp.Body.Close() resp.Body.Close()
...@@ -120,6 +152,10 @@ func UpdateMidjourneyTaskBulk() { ...@@ -120,6 +152,10 @@ func UpdateMidjourneyTaskBulk() {
for _, responseItem := range responseItems { for _, responseItem := range responseItems {
task := taskM[responseItem.MjId] task := taskM[responseItem.MjId]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
continue
}
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
// 如果时间超过一小时,且进度不是100%,则认为任务失败 // 如果时间超过一小时,且进度不是100%,则认为任务失败
...@@ -142,11 +178,11 @@ func UpdateMidjourneyTaskBulk() { ...@@ -142,11 +178,11 @@ func UpdateMidjourneyTaskBulk() {
task.Status = responseItem.Status task.Status = responseItem.Status
task.FailReason = responseItem.FailReason task.FailReason = responseItem.FailReason
if responseItem.Properties != nil { if responseItem.Properties != nil {
propertiesStr, _ := json.Marshal(responseItem.Properties) propertiesStr, _ := common.Marshal(responseItem.Properties)
task.Properties = string(propertiesStr) task.Properties = string(propertiesStr)
} }
if responseItem.Buttons != nil { if responseItem.Buttons != nil {
buttonStr, _ := json.Marshal(responseItem.Buttons) buttonStr, _ := common.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr) task.Buttons = string(buttonStr)
} }
// 映射 VideoUrl // 映射 VideoUrl
...@@ -154,7 +190,7 @@ func UpdateMidjourneyTaskBulk() { ...@@ -154,7 +190,7 @@ func UpdateMidjourneyTaskBulk() {
// 映射 VideoUrls - 将数组序列化为 JSON 字符串 // 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 { if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls) videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err)) logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组 task.VideoUrls = "[]" // 失败时设置为空数组
...@@ -196,7 +232,10 @@ func UpdateMidjourneyTaskBulk() { ...@@ -196,7 +232,10 @@ func UpdateMidjourneyTaskBulk() {
} }
} }
} }
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(totalChannels, totalChannels)
} }
return summary
} }
func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool { func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool {
...@@ -242,7 +281,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) ...@@ -242,7 +281,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
} }
// 检查 VideoUrls 是否需要更新 // 检查 VideoUrls 是否需要更新
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 { if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls) newVideoUrlsStr, _ := common.Marshal(newTask.VideoUrls)
if oldTask.VideoUrls != string(newVideoUrlsStr) { if oldTask.VideoUrls != string(newVideoUrlsStr) {
return true return true
} }
......
...@@ -65,6 +65,27 @@ func GetCurrentSystemTask(c *gin.Context) { ...@@ -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) { func GetSystemTask(c *gin.Context) {
taskID := c.Param("task_id") taskID := c.Param("task_id")
if taskID == "" { 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 ( ...@@ -8,17 +8,11 @@ import (
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay" "github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// UpdateTaskBulk 薄入口,实际轮询逻辑在 service 层
func UpdateTaskBulk() {
service.TaskPollingLoop()
}
func GetAllTask(c *gin.Context) { func GetAllTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c) pageInfo := common.GetPageQuery(c)
......
...@@ -111,18 +111,15 @@ func main() { ...@@ -111,18 +111,15 @@ func main() {
go controller.AutomaticallyUpdateChannels(frequency) go controller.AutomaticallyUpdateChannels(frequency)
} }
go controller.AutomaticallyTestChannels()
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day // Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
service.StartCodexCredentialAutoRefreshTask() service.StartCodexCredentialAutoRefreshTask()
// Subscription quota reset task (daily/weekly/monthly/custom) // Subscription quota reset task (daily/weekly/monthly/custom)
service.StartSubscriptionQuotaResetTask() service.StartSubscriptionQuotaResetTask()
// Persistent system maintenance task runner // Wire task polling adaptor factory (breaks service -> relay import cycle).
service.StartSystemTaskRunner() // Must run before the system task runner starts: the async_task_poll handler
// calls service.RunTaskPollingOnce, which needs this factory set.
// Wire task polling adaptor factory (breaks service -> relay import cycle)
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
a := relay.GetTaskAdaptor(platform) a := relay.GetTaskAdaptor(platform)
if a == nil { if a == nil {
...@@ -131,17 +128,14 @@ func main() { ...@@ -131,17 +128,14 @@ func main() {
return a return a
} }
// Channel upstream model update check task // Register the periodic channel test, upstream model update, and async task
controller.StartChannelUpstreamModelUpdateTask() // 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" { if os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
common.BatchUpdateEnabled = true common.BatchUpdateEnabled = true
common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s") common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
......
...@@ -295,6 +295,7 @@ func migrateDB() error { ...@@ -295,6 +295,7 @@ func migrateDB() error {
&UserOAuthBinding{}, &UserOAuthBinding{},
&PerfMetric{}, &PerfMetric{},
&SystemTask{}, &SystemTask{},
&SystemTaskLock{},
) )
if err != nil { if err != nil {
return err return err
...@@ -345,6 +346,7 @@ func migrateDBFast() error { ...@@ -345,6 +346,7 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"}, {&UserOAuthBinding{}, "UserOAuthBinding"},
{&PerfMetric{}, "PerfMetric"}, {&PerfMetric{}, "PerfMetric"},
{&SystemTask{}, "SystemTask"}, {&SystemTask{}, "SystemTask"},
{&SystemTaskLock{}, "SystemTaskLock"},
} }
// 动态计算migration数量,确保errChan缓冲区足够大 // 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations)) errChan := make(chan error, len(migrations))
......
...@@ -101,6 +101,19 @@ func GetAllUnFinishTasks() []*Midjourney { ...@@ -101,6 +101,19 @@ func GetAllUnFinishTasks() []*Midjourney {
return tasks 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 { func GetByOnlyMJId(mjId string) *Midjourney {
var mj *Midjourney var mj *Midjourney
var err error var err error
......
...@@ -314,6 +314,21 @@ func GetAllUnFinishSyncTasks(limit int) []*Task { ...@@ -314,6 +314,21 @@ func GetAllUnFinishSyncTasks(limit int) []*Task {
return tasks 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) { func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" { if taskId == "" {
return nil, false, nil return nil, false, nil
......
...@@ -49,6 +49,7 @@ func TestMain(m *testing.M) { ...@@ -49,6 +49,7 @@ func TestMain(m *testing.M) {
&UserOAuthBinding{}, &UserOAuthBinding{},
&PerfMetric{}, &PerfMetric{},
&SystemTask{}, &SystemTask{},
&SystemTaskLock{},
); err != nil { ); err != nil {
panic("failed to migrate: " + err.Error()) panic("failed to migrate: " + err.Error())
} }
...@@ -72,6 +73,7 @@ func truncateTables(t *testing.T) { ...@@ -72,6 +73,7 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM user_subscriptions") DB.Exec("DELETE FROM user_subscriptions")
DB.Exec("DELETE FROM user_oauth_bindings") DB.Exec("DELETE FROM user_oauth_bindings")
DB.Exec("DELETE FROM perf_metrics") DB.Exec("DELETE FROM perf_metrics")
DB.Exec("DELETE FROM system_task_locks")
DB.Exec("DELETE FROM system_tasks") DB.Exec("DELETE FROM system_tasks")
}) })
} }
......
...@@ -318,6 +318,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -318,6 +318,7 @@ func SetApiRouter(router *gin.Engine) {
systemTaskRoute.Use(middleware.RootAuth()) systemTaskRoute.Use(middleware.RootAuth())
{ {
systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask) systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask)
systemTaskRoute.GET("/list", controller.ListSystemTasks)
systemTaskRoute.GET("/current", controller.GetCurrentSystemTask) systemTaskRoute.GET("/current", controller.GetCurrentSystemTask)
systemTaskRoute.GET("/:task_id", controller.GetSystemTask) systemTaskRoute.GET("/:task_id", controller.GetSystemTask)
} }
......
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) { ...@@ -45,6 +45,7 @@ func TestMain(m *testing.M) {
&model.TopUp{}, &model.TopUp{},
&model.UserSubscription{}, &model.UserSubscription{},
&model.SystemTask{}, &model.SystemTask{},
&model.SystemTaskLock{},
); err != nil { ); err != nil {
panic("failed to migrate: " + err.Error()) panic("failed to migrate: " + err.Error())
} }
...@@ -66,6 +67,7 @@ func truncate(t *testing.T) { ...@@ -66,6 +67,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM channels") model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM top_ups") model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions") model.DB.Exec("DELETE FROM user_subscriptions")
model.DB.Exec("DELETE FROM system_task_locks")
model.DB.Exec("DELETE FROM system_tasks") model.DB.Exec("DELETE FROM system_tasks")
}) })
} }
......
...@@ -87,22 +87,51 @@ func sweepTimedOutTasks(ctx context.Context) { ...@@ -87,22 +87,51 @@ func sweepTimedOutTasks(ctx context.Context) {
} }
} }
// TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务 // TaskPollSummary is the result recorded on an async_task_poll system task row,
func TaskPollingLoop() { // summarizing one polling pass.
for { type TaskPollSummary struct {
time.Sleep(time.Duration(15) * time.Second) 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("任务进度轮询开始") common.SysLog("任务进度轮询开始")
ctx := context.TODO()
sweepTimedOutTasks(ctx) sweepTimedOutTasks(ctx)
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit) allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
summary.UnfinishedTasks = len(allTasks)
platformTask := make(map[constant.TaskPlatform][]*model.Task) platformTask := make(map[constant.TaskPlatform][]*model.Task)
for _, t := range allTasks { for _, t := range allTasks {
platformTask[t.Platform] = append(platformTask[t.Platform], t) platformTask[t.Platform] = append(platformTask[t.Platform], t)
} }
totalPlatforms := len(platformTask)
processedPlatforms := 0
for platform, tasks := range platformTask { for platform, tasks := range platformTask {
if ctx.Err() != nil {
break
}
if report != nil {
report(processedPlatforms, totalPlatforms)
}
processedPlatforms++
if len(tasks) == 0 { if len(tasks) == 0 {
continue continue
} }
summary.PlatformsScanned++
taskChannelM := make(map[int][]string) taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Task) taskM := make(map[string]*model.Task)
nullTaskIds := make([]int64, 0) nullTaskIds := make([]int64, 0)
...@@ -117,6 +146,7 @@ func TaskPollingLoop() { ...@@ -117,6 +146,7 @@ func TaskPollingLoop() {
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID) taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
} }
if len(nullTaskIds) > 0 { if len(nullTaskIds) > 0 {
summary.NullTasksFailed += len(nullTaskIds)
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{ err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
"status": "FAILURE", "status": "FAILURE",
"progress": "100%", "progress": "100%",
...@@ -131,21 +161,27 @@ func TaskPollingLoop() { ...@@ -131,21 +161,27 @@ func TaskPollingLoop() {
continue continue
} }
DispatchPlatformUpdate(platform, taskChannelM, taskM) DispatchPlatformUpdate(ctx, platform, taskChannelM, taskM)
} }
common.SysLog("任务进度轮询完成") if report != nil && ctx.Err() == nil {
report(totalPlatforms, totalPlatforms)
} }
common.SysLog("任务进度轮询完成")
return summary
} }
// DispatchPlatformUpdate 按平台分发轮询更新 // 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 { switch platform {
case constant.TaskPlatformMidjourney: case constant.TaskPlatformMidjourney:
// MJ 轮询由其自身处理,这里预留入口 // MJ 轮询由其自身处理,这里预留入口
case constant.TaskPlatformSuno: case constant.TaskPlatformSuno:
_ = UpdateSunoTasks(context.Background(), taskChannelM, taskM) _ = UpdateSunoTasks(ctx, taskChannelM, taskM)
default: 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)) common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
} }
} }
...@@ -154,6 +190,9 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int ...@@ -154,6 +190,9 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int
// UpdateSunoTasks 按渠道更新所有 Suno 任务 // UpdateSunoTasks 按渠道更新所有 Suno 任务
func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM { for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
err := updateSunoTasks(ctx, channelId, taskIds, taskM) err := updateSunoTasks(ctx, channelId, taskIds, taskM)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error())) 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 ...@@ -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 { 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))) logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
}
if len(taskIds) == 0 { if len(taskIds) == 0 {
return nil return nil
} }
...@@ -221,7 +263,14 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM ...@@ -221,7 +263,14 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
} }
for _, responseItem := range responseItems.Data { for _, responseItem := range responseItems.Data {
if ctx.Err() != nil {
return ctx.Err()
}
task := taskM[responseItem.TaskID] 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) { if !taskNeedsUpdate(task, responseItem) {
continue continue
} }
...@@ -290,6 +339,9 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool { ...@@ -290,6 +339,9 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool {
// UpdateVideoTasks 按渠道更新所有视频任务 // UpdateVideoTasks 按渠道更新所有视频任务
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM { for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil { 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())) 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 ...@@ -332,16 +384,26 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann
info.ApiKey = cacheGetChannel.Key info.ApiKey = cacheGetChannel.Key
adaptor.Init(info) adaptor.Init(info)
for _, taskId := range taskIds { for _, taskId := range taskIds {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { 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())) 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 // 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 return nil
} }
func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error { 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] baseURL := constant.ChannelBaseURLs[ch.Type]
if ch.GetBaseURL() != "" { if ch.GetBaseURL() != "" {
baseURL = ch.GetBaseURL() baseURL = ch.GetBaseURL()
......
...@@ -28,6 +28,12 @@ type BaseNavItem = { ...@@ -28,6 +28,12 @@ type BaseNavItem = {
icon?: React.ElementType icon?: React.ElementType
activeUrls?: (LinkProps['to'] | (string & {}))[] activeUrls?: (LinkProps['to'] | (string & {}))[]
configUrls?: (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>) { ...@@ -251,7 +251,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{}, {},
upstreamUpdateRequestConfig upstreamUpdateRequestConfig
) )
const { success, message, data } = res.data || {} const { success, message } = res.data || {}
if (!success) { if (!success) {
toast.error(message || t('Batch detection failed')) toast.error(message || t('Batch detection failed'))
return return
...@@ -259,13 +259,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) { ...@@ -259,13 +259,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
toast.success( toast.success(
t( t(
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed', 'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
{
channels: data?.processed_channels || 0,
add: data?.detected_add_models || 0,
remove: data?.detected_remove_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
) )
) )
await refresh() 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 { 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 { ...@@ -22,6 +22,7 @@ import type {
FetchUpstreamRatiosRequest, FetchUpstreamRatiosRequest,
LogCleanupTask, LogCleanupTask,
SystemOptionsResponse, SystemOptionsResponse,
SystemTaskListResponse,
SystemTaskResponse, SystemTaskResponse,
UpdateOptionRequest, UpdateOptionRequest,
UpdateOptionResponse, UpdateOptionResponse,
...@@ -75,6 +76,13 @@ export async function getSystemTask(taskId: string) { ...@@ -75,6 +76,13 @@ export async function getSystemTask(taskId: string) {
return res.data 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() { export async function resetModelRatios() {
const res = await api.post<UpdateOptionResponse>( const res = await api.post<UpdateOptionResponse>(
'/api/option/rest_model_ratio' '/api/option/rest_model_ratio'
......
...@@ -100,6 +100,12 @@ export type SystemTaskResponse<TTask = SystemTask | null> = { ...@@ -100,6 +100,12 @@ export type SystemTaskResponse<TTask = SystemTask | null> = {
data?: TTask data?: TTask
} }
export type SystemTaskListResponse = {
success: boolean
message: string
data?: SystemTask[]
}
export type SiteSettings = { export type SiteSettings = {
'theme.frontend': string 'theme.frontend': string
Notice: string Notice: string
......
...@@ -27,6 +27,7 @@ import { ...@@ -27,6 +27,7 @@ import {
ListTodo, ListTodo,
MessageSquare, MessageSquare,
Radio, Radio,
ServerCog,
Settings, Settings,
Ticket, Ticket,
User, User,
...@@ -34,6 +35,7 @@ import { ...@@ -34,6 +35,7 @@ import {
Wallet, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ROLE } from '@/lib/roles'
import { type SidebarData } from '@/components/layout/types' import { type SidebarData } from '@/components/layout/types'
/** /**
...@@ -142,6 +144,12 @@ export function useSidebarData(): SidebarData { ...@@ -142,6 +144,12 @@ export function useSidebarData(): SidebarData {
icon: CreditCard, icon: CreditCard,
}, },
{ {
title: t('System Info'),
url: '/system-info',
icon: ServerCog,
requiredRole: ROLE.SUPER_ADMIN,
},
{
title: t('System Settings'), title: t('System Settings'),
url: '/system-settings/site', url: '/system-settings/site',
activeUrls: ['/system-settings'], activeUrls: ['/system-settings'],
......
...@@ -50,10 +50,16 @@ export function useSidebarView(): ResolvedSidebarView { ...@@ -50,10 +50,16 @@ export function useSidebarView(): ResolvedSidebarView {
const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups) const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups)
const rootNavGroups = useMemo<NavGroup[]>(() => { const rootNavGroups = useMemo<NavGroup[]>(() => {
const isAdmin = userRole !== undefined && userRole >= ROLE.ADMIN const role = userRole ?? ROLE.GUEST
return configFilteredRoot.filter((group) => const isAdmin = role >= ROLE.ADMIN
group.id === 'admin' ? isAdmin : true 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]) }, [configFilteredRoot, userRole])
const view = resolveSidebarView(pathname) const view = resolveSidebarView(pathname)
......
...@@ -40,6 +40,7 @@ import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenti ...@@ -40,6 +40,7 @@ import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenti
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index' import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index' import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/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 AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index' import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index' import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
...@@ -226,6 +227,12 @@ const AuthenticatedSystemSettingsIndexRoute = ...@@ -226,6 +227,12 @@ const AuthenticatedSystemSettingsIndexRoute =
path: '/', path: '/',
getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, getParentRoute: () => AuthenticatedSystemSettingsRouteRoute,
} as any) } as any)
const AuthenticatedSystemInfoIndexRoute =
AuthenticatedSystemInfoIndexRouteImport.update({
id: '/system-info/',
path: '/system-info/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSubscriptionsIndexRoute = const AuthenticatedSubscriptionsIndexRoute =
AuthenticatedSubscriptionsIndexRouteImport.update({ AuthenticatedSubscriptionsIndexRouteImport.update({
id: '/subscriptions/', id: '/subscriptions/',
...@@ -431,6 +438,7 @@ export interface FileRoutesByFullPath { ...@@ -431,6 +438,7 @@ export interface FileRoutesByFullPath {
'/profile/': typeof AuthenticatedProfileIndexRoute '/profile/': typeof AuthenticatedProfileIndexRoute
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute '/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute '/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute '/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute '/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute '/users/': typeof AuthenticatedUsersIndexRoute
...@@ -489,6 +497,7 @@ export interface FileRoutesByTo { ...@@ -489,6 +497,7 @@ export interface FileRoutesByTo {
'/profile': typeof AuthenticatedProfileIndexRoute '/profile': typeof AuthenticatedProfileIndexRoute
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute '/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute '/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute '/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute '/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute '/users': typeof AuthenticatedUsersIndexRoute
...@@ -551,6 +560,7 @@ export interface FileRoutesById { ...@@ -551,6 +560,7 @@ export interface FileRoutesById {
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute '/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute '/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute '/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute '/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute '/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute '/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
...@@ -612,6 +622,7 @@ export interface FileRouteTypes { ...@@ -612,6 +622,7 @@ export interface FileRouteTypes {
| '/profile/' | '/profile/'
| '/redemption-codes/' | '/redemption-codes/'
| '/subscriptions/' | '/subscriptions/'
| '/system-info/'
| '/system-settings/' | '/system-settings/'
| '/usage-logs/' | '/usage-logs/'
| '/users/' | '/users/'
...@@ -670,6 +681,7 @@ export interface FileRouteTypes { ...@@ -670,6 +681,7 @@ export interface FileRouteTypes {
| '/profile' | '/profile'
| '/redemption-codes' | '/redemption-codes'
| '/subscriptions' | '/subscriptions'
| '/system-info'
| '/system-settings' | '/system-settings'
| '/usage-logs' | '/usage-logs'
| '/users' | '/users'
...@@ -731,6 +743,7 @@ export interface FileRouteTypes { ...@@ -731,6 +743,7 @@ export interface FileRouteTypes {
| '/_authenticated/profile/' | '/_authenticated/profile/'
| '/_authenticated/redemption-codes/' | '/_authenticated/redemption-codes/'
| '/_authenticated/subscriptions/' | '/_authenticated/subscriptions/'
| '/_authenticated/system-info/'
| '/_authenticated/system-settings/' | '/_authenticated/system-settings/'
| '/_authenticated/usage-logs/' | '/_authenticated/usage-logs/'
| '/_authenticated/users/' | '/_authenticated/users/'
...@@ -992,6 +1005,13 @@ declare module '@tanstack/react-router' { ...@@ -992,6 +1005,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSystemSettingsIndexRouteImport preLoaderRoute: typeof AuthenticatedSystemSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSystemSettingsRouteRoute parentRoute: typeof AuthenticatedSystemSettingsRouteRoute
} }
'/_authenticated/system-info/': {
id: '/_authenticated/system-info/'
path: '/system-info'
fullPath: '/system-info/'
preLoaderRoute: typeof AuthenticatedSystemInfoIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/subscriptions/': { '/_authenticated/subscriptions/': {
id: '/_authenticated/subscriptions/' id: '/_authenticated/subscriptions/'
path: '/subscriptions' path: '/subscriptions'
...@@ -1290,6 +1310,7 @@ interface AuthenticatedRouteRouteChildren { ...@@ -1290,6 +1310,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedSystemInfoIndexRoute: typeof AuthenticatedSystemInfoIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
...@@ -1313,6 +1334,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { ...@@ -1313,6 +1334,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedRedemptionCodesIndexRoute: AuthenticatedRedemptionCodesIndexRoute:
AuthenticatedRedemptionCodesIndexRoute, AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute, AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
AuthenticatedSystemInfoIndexRoute: AuthenticatedSystemInfoIndexRoute,
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute, AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute, AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute, 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