Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
642432cf
Unverified
Commit
642432cf
authored
Mar 05, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 05, 2026
Browse files
Merge pull request #777 from guoyongchang/feature-schedule-test-support
feat: 支持基于 crontab 的定时账号测试
parents
9d70c385
d4e34c75
Changes
23
Hide whitespace changes
Inline
Side-by-side
backend/cmd/server/wire.go
View file @
642432cf
...
...
@@ -86,6 +86,7 @@ func provideCleanup(
geminiOAuth
*
service
.
GeminiOAuthService
,
antigravityOAuth
*
service
.
AntigravityOAuthService
,
openAIGateway
*
service
.
OpenAIGatewayService
,
scheduledTestRunner
*
service
.
ScheduledTestRunnerService
,
)
func
()
{
return
func
()
{
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
10
*
time
.
Second
)
...
...
@@ -216,6 +217,12 @@ func provideCleanup(
}
return
nil
}},
{
"ScheduledTestRunnerService"
,
func
()
error
{
if
scheduledTestRunner
!=
nil
{
scheduledTestRunner
.
Stop
()
}
return
nil
}},
}
infraSteps
:=
[]
cleanupStep
{
...
...
backend/cmd/server/wire_gen.go
View file @
642432cf
...
...
@@ -195,7 +195,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
errorPassthroughService
:=
service
.
NewErrorPassthroughService
(
errorPassthroughRepository
,
errorPassthroughCache
)
errorPassthroughHandler
:=
admin
.
NewErrorPassthroughHandler
(
errorPassthroughService
)
adminAPIKeyHandler
:=
admin
.
NewAdminAPIKeyHandler
(
adminService
)
adminHandlers
:=
handler
.
ProvideAdminHandlers
(
dashboardHandler
,
adminUserHandler
,
groupHandler
,
accountHandler
,
adminAnnouncementHandler
,
dataManagementHandler
,
oAuthHandler
,
openAIOAuthHandler
,
geminiOAuthHandler
,
antigravityOAuthHandler
,
proxyHandler
,
adminRedeemHandler
,
promoHandler
,
settingHandler
,
opsHandler
,
systemHandler
,
adminSubscriptionHandler
,
adminUsageHandler
,
userAttributeHandler
,
errorPassthroughHandler
,
adminAPIKeyHandler
)
scheduledTestPlanRepository
:=
repository
.
NewScheduledTestPlanRepository
(
db
)
scheduledTestResultRepository
:=
repository
.
NewScheduledTestResultRepository
(
db
)
scheduledTestService
:=
service
.
ProvideScheduledTestService
(
scheduledTestPlanRepository
,
scheduledTestResultRepository
)
scheduledTestHandler
:=
admin
.
NewScheduledTestHandler
(
scheduledTestService
)
adminHandlers
:=
handler
.
ProvideAdminHandlers
(
dashboardHandler
,
adminUserHandler
,
groupHandler
,
accountHandler
,
adminAnnouncementHandler
,
dataManagementHandler
,
oAuthHandler
,
openAIOAuthHandler
,
geminiOAuthHandler
,
antigravityOAuthHandler
,
proxyHandler
,
adminRedeemHandler
,
promoHandler
,
settingHandler
,
opsHandler
,
systemHandler
,
adminSubscriptionHandler
,
adminUsageHandler
,
userAttributeHandler
,
errorPassthroughHandler
,
adminAPIKeyHandler
,
scheduledTestHandler
)
usageRecordWorkerPool
:=
service
.
NewUsageRecordWorkerPool
(
configConfig
)
userMsgQueueCache
:=
repository
.
NewUserMsgQueueCache
(
redisClient
)
userMessageQueueService
:=
service
.
ProvideUserMessageQueueService
(
userMsgQueueCache
,
rpmCache
,
configConfig
)
...
...
@@ -225,7 +229,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tokenRefreshService
:=
service
.
ProvideTokenRefreshService
(
accountRepository
,
soraAccountRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
compositeTokenCacheInvalidator
,
schedulerCache
,
configConfig
,
tempUnschedCache
)
accountExpiryService
:=
service
.
ProvideAccountExpiryService
(
accountRepository
)
subscriptionExpiryService
:=
service
.
ProvideSubscriptionExpiryService
(
userSubscriptionRepository
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
idempotencyCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
usageRecordWorkerPool
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
openAIGatewayService
)
scheduledTestRunnerService
:=
service
.
ProvideScheduledTestRunnerService
(
scheduledTestPlanRepository
,
scheduledTestService
,
accountTestService
,
configConfig
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
idempotencyCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
usageRecordWorkerPool
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
openAIGatewayService
,
scheduledTestRunnerService
)
application
:=
&
Application
{
Server
:
httpServer
,
Cleanup
:
v
,
...
...
@@ -273,6 +278,7 @@ func provideCleanup(
geminiOAuth
*
service
.
GeminiOAuthService
,
antigravityOAuth
*
service
.
AntigravityOAuthService
,
openAIGateway
*
service
.
OpenAIGatewayService
,
scheduledTestRunner
*
service
.
ScheduledTestRunnerService
,
)
func
()
{
return
func
()
{
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
10
*
time
.
Second
)
...
...
@@ -402,6 +408,12 @@ func provideCleanup(
}
return
nil
}},
{
"ScheduledTestRunnerService"
,
func
()
error
{
if
scheduledTestRunner
!=
nil
{
scheduledTestRunner
.
Stop
()
}
return
nil
}},
}
infraSteps
:=
[]
cleanupStep
{
...
...
backend/cmd/server/wire_gen_test.go
View file @
642432cf
...
...
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
geminiOAuthSvc
,
antigravityOAuthSvc
,
nil
,
// openAIGateway
nil
,
// scheduledTestRunner
)
require
.
NotPanics
(
t
,
func
()
{
...
...
backend/internal/handler/admin/scheduled_test_handler.go
0 → 100644
View file @
642432cf
package
admin
import
(
"net/http"
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ScheduledTestHandler handles admin scheduled-test-plan management.
type
ScheduledTestHandler
struct
{
scheduledTestSvc
*
service
.
ScheduledTestService
}
// NewScheduledTestHandler creates a new ScheduledTestHandler.
func
NewScheduledTestHandler
(
scheduledTestSvc
*
service
.
ScheduledTestService
)
*
ScheduledTestHandler
{
return
&
ScheduledTestHandler
{
scheduledTestSvc
:
scheduledTestSvc
}
}
type
createScheduledTestPlanRequest
struct
{
AccountID
int64
`json:"account_id" binding:"required"`
ModelID
string
`json:"model_id"`
CronExpression
string
`json:"cron_expression" binding:"required"`
Enabled
*
bool
`json:"enabled"`
MaxResults
int
`json:"max_results"`
}
type
updateScheduledTestPlanRequest
struct
{
ModelID
string
`json:"model_id"`
CronExpression
string
`json:"cron_expression"`
Enabled
*
bool
`json:"enabled"`
MaxResults
int
`json:"max_results"`
}
// ListByAccount GET /admin/accounts/:id/scheduled-test-plans
func
(
h
*
ScheduledTestHandler
)
ListByAccount
(
c
*
gin
.
Context
)
{
accountID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"invalid account id"
)
return
}
plans
,
err
:=
h
.
scheduledTestSvc
.
ListPlansByAccount
(
c
.
Request
.
Context
(),
accountID
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
err
.
Error
())
return
}
c
.
JSON
(
http
.
StatusOK
,
plans
)
}
// Create POST /admin/scheduled-test-plans
func
(
h
*
ScheduledTestHandler
)
Create
(
c
*
gin
.
Context
)
{
var
req
createScheduledTestPlanRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
plan
:=
&
service
.
ScheduledTestPlan
{
AccountID
:
req
.
AccountID
,
ModelID
:
req
.
ModelID
,
CronExpression
:
req
.
CronExpression
,
Enabled
:
true
,
MaxResults
:
req
.
MaxResults
,
}
if
req
.
Enabled
!=
nil
{
plan
.
Enabled
=
*
req
.
Enabled
}
created
,
err
:=
h
.
scheduledTestSvc
.
CreatePlan
(
c
.
Request
.
Context
(),
plan
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
c
.
JSON
(
http
.
StatusOK
,
created
)
}
// Update PUT /admin/scheduled-test-plans/:id
func
(
h
*
ScheduledTestHandler
)
Update
(
c
*
gin
.
Context
)
{
planID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"invalid plan id"
)
return
}
existing
,
err
:=
h
.
scheduledTestSvc
.
GetPlan
(
c
.
Request
.
Context
(),
planID
)
if
err
!=
nil
{
response
.
NotFound
(
c
,
"plan not found"
)
return
}
var
req
updateScheduledTestPlanRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
if
req
.
ModelID
!=
""
{
existing
.
ModelID
=
req
.
ModelID
}
if
req
.
CronExpression
!=
""
{
existing
.
CronExpression
=
req
.
CronExpression
}
if
req
.
Enabled
!=
nil
{
existing
.
Enabled
=
*
req
.
Enabled
}
if
req
.
MaxResults
>
0
{
existing
.
MaxResults
=
req
.
MaxResults
}
updated
,
err
:=
h
.
scheduledTestSvc
.
UpdatePlan
(
c
.
Request
.
Context
(),
existing
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
c
.
JSON
(
http
.
StatusOK
,
updated
)
}
// Delete DELETE /admin/scheduled-test-plans/:id
func
(
h
*
ScheduledTestHandler
)
Delete
(
c
*
gin
.
Context
)
{
planID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"invalid plan id"
)
return
}
if
err
:=
h
.
scheduledTestSvc
.
DeletePlan
(
c
.
Request
.
Context
(),
planID
);
err
!=
nil
{
response
.
InternalError
(
c
,
err
.
Error
())
return
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"message"
:
"deleted"
})
}
// ListResults GET /admin/scheduled-test-plans/:id/results
func
(
h
*
ScheduledTestHandler
)
ListResults
(
c
*
gin
.
Context
)
{
planID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"invalid plan id"
)
return
}
limit
:=
50
if
l
,
err
:=
strconv
.
Atoi
(
c
.
Query
(
"limit"
));
err
==
nil
&&
l
>
0
{
limit
=
l
}
results
,
err
:=
h
.
scheduledTestSvc
.
ListResults
(
c
.
Request
.
Context
(),
planID
,
limit
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
err
.
Error
())
return
}
c
.
JSON
(
http
.
StatusOK
,
results
)
}
backend/internal/handler/handler.go
View file @
642432cf
...
...
@@ -27,6 +27,7 @@ type AdminHandlers struct {
UserAttribute
*
admin
.
UserAttributeHandler
ErrorPassthrough
*
admin
.
ErrorPassthroughHandler
APIKey
*
admin
.
AdminAPIKeyHandler
ScheduledTest
*
admin
.
ScheduledTestHandler
}
// Handlers contains all HTTP handlers
...
...
backend/internal/handler/wire.go
View file @
642432cf
...
...
@@ -30,6 +30,7 @@ func ProvideAdminHandlers(
userAttributeHandler
*
admin
.
UserAttributeHandler
,
errorPassthroughHandler
*
admin
.
ErrorPassthroughHandler
,
apiKeyHandler
*
admin
.
AdminAPIKeyHandler
,
scheduledTestHandler
*
admin
.
ScheduledTestHandler
,
)
*
AdminHandlers
{
return
&
AdminHandlers
{
Dashboard
:
dashboardHandler
,
...
...
@@ -53,6 +54,7 @@ func ProvideAdminHandlers(
UserAttribute
:
userAttributeHandler
,
ErrorPassthrough
:
errorPassthroughHandler
,
APIKey
:
apiKeyHandler
,
ScheduledTest
:
scheduledTestHandler
,
}
}
...
...
@@ -141,6 +143,7 @@ var ProviderSet = wire.NewSet(
admin
.
NewUserAttributeHandler
,
admin
.
NewErrorPassthroughHandler
,
admin
.
NewAdminAPIKeyHandler
,
admin
.
NewScheduledTestHandler
,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers
,
...
...
backend/internal/repository/scheduled_test_repo.go
0 → 100644
View file @
642432cf
package
repository
import
(
"context"
"database/sql"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// --- Plan Repository ---
type
scheduledTestPlanRepository
struct
{
db
*
sql
.
DB
}
func
NewScheduledTestPlanRepository
(
db
*
sql
.
DB
)
service
.
ScheduledTestPlanRepository
{
return
&
scheduledTestPlanRepository
{
db
:
db
}
}
func
(
r
*
scheduledTestPlanRepository
)
Create
(
ctx
context
.
Context
,
plan
*
service
.
ScheduledTestPlan
)
(
*
service
.
ScheduledTestPlan
,
error
)
{
row
:=
r
.
db
.
QueryRowContext
(
ctx
,
`
INSERT INTO scheduled_test_plans (account_id, model_id, cron_expression, enabled, max_results, next_run_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING id, account_id, model_id, cron_expression, enabled, max_results, last_run_at, next_run_at, created_at, updated_at
`
,
plan
.
AccountID
,
plan
.
ModelID
,
plan
.
CronExpression
,
plan
.
Enabled
,
plan
.
MaxResults
,
plan
.
NextRunAt
)
return
scanPlan
(
row
)
}
func
(
r
*
scheduledTestPlanRepository
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
ScheduledTestPlan
,
error
)
{
row
:=
r
.
db
.
QueryRowContext
(
ctx
,
`
SELECT id, account_id, model_id, cron_expression, enabled, max_results, last_run_at, next_run_at, created_at, updated_at
FROM scheduled_test_plans WHERE id = $1
`
,
id
)
return
scanPlan
(
row
)
}
func
(
r
*
scheduledTestPlanRepository
)
ListByAccountID
(
ctx
context
.
Context
,
accountID
int64
)
([]
*
service
.
ScheduledTestPlan
,
error
)
{
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
`
SELECT id, account_id, model_id, cron_expression, enabled, max_results, last_run_at, next_run_at, created_at, updated_at
FROM scheduled_test_plans WHERE account_id = $1
ORDER BY created_at DESC
`
,
accountID
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
return
scanPlans
(
rows
)
}
func
(
r
*
scheduledTestPlanRepository
)
ListDue
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
*
service
.
ScheduledTestPlan
,
error
)
{
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
`
SELECT id, account_id, model_id, cron_expression, enabled, max_results, last_run_at, next_run_at, created_at, updated_at
FROM scheduled_test_plans
WHERE enabled = true AND next_run_at <= $1
ORDER BY next_run_at ASC
`
,
now
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
return
scanPlans
(
rows
)
}
func
(
r
*
scheduledTestPlanRepository
)
Update
(
ctx
context
.
Context
,
plan
*
service
.
ScheduledTestPlan
)
(
*
service
.
ScheduledTestPlan
,
error
)
{
row
:=
r
.
db
.
QueryRowContext
(
ctx
,
`
UPDATE scheduled_test_plans
SET model_id = $2, cron_expression = $3, enabled = $4, max_results = $5, next_run_at = $6, updated_at = NOW()
WHERE id = $1
RETURNING id, account_id, model_id, cron_expression, enabled, max_results, last_run_at, next_run_at, created_at, updated_at
`
,
plan
.
ID
,
plan
.
ModelID
,
plan
.
CronExpression
,
plan
.
Enabled
,
plan
.
MaxResults
,
plan
.
NextRunAt
)
return
scanPlan
(
row
)
}
func
(
r
*
scheduledTestPlanRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
_
,
err
:=
r
.
db
.
ExecContext
(
ctx
,
`DELETE FROM scheduled_test_plans WHERE id = $1`
,
id
)
return
err
}
func
(
r
*
scheduledTestPlanRepository
)
UpdateAfterRun
(
ctx
context
.
Context
,
id
int64
,
lastRunAt
time
.
Time
,
nextRunAt
time
.
Time
)
error
{
_
,
err
:=
r
.
db
.
ExecContext
(
ctx
,
`
UPDATE scheduled_test_plans SET last_run_at = $2, next_run_at = $3, updated_at = NOW() WHERE id = $1
`
,
id
,
lastRunAt
,
nextRunAt
)
return
err
}
// --- Result Repository ---
type
scheduledTestResultRepository
struct
{
db
*
sql
.
DB
}
func
NewScheduledTestResultRepository
(
db
*
sql
.
DB
)
service
.
ScheduledTestResultRepository
{
return
&
scheduledTestResultRepository
{
db
:
db
}
}
func
(
r
*
scheduledTestResultRepository
)
Create
(
ctx
context
.
Context
,
result
*
service
.
ScheduledTestResult
)
(
*
service
.
ScheduledTestResult
,
error
)
{
row
:=
r
.
db
.
QueryRowContext
(
ctx
,
`
INSERT INTO scheduled_test_results (plan_id, status, response_text, error_message, latency_ms, started_at, finished_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
RETURNING id, plan_id, status, response_text, error_message, latency_ms, started_at, finished_at, created_at
`
,
result
.
PlanID
,
result
.
Status
,
result
.
ResponseText
,
result
.
ErrorMessage
,
result
.
LatencyMs
,
result
.
StartedAt
,
result
.
FinishedAt
)
out
:=
&
service
.
ScheduledTestResult
{}
if
err
:=
row
.
Scan
(
&
out
.
ID
,
&
out
.
PlanID
,
&
out
.
Status
,
&
out
.
ResponseText
,
&
out
.
ErrorMessage
,
&
out
.
LatencyMs
,
&
out
.
StartedAt
,
&
out
.
FinishedAt
,
&
out
.
CreatedAt
,
);
err
!=
nil
{
return
nil
,
err
}
return
out
,
nil
}
func
(
r
*
scheduledTestResultRepository
)
ListByPlanID
(
ctx
context
.
Context
,
planID
int64
,
limit
int
)
([]
*
service
.
ScheduledTestResult
,
error
)
{
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
`
SELECT id, plan_id, status, response_text, error_message, latency_ms, started_at, finished_at, created_at
FROM scheduled_test_results
WHERE plan_id = $1
ORDER BY created_at DESC
LIMIT $2
`
,
planID
,
limit
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
var
results
[]
*
service
.
ScheduledTestResult
for
rows
.
Next
()
{
r
:=
&
service
.
ScheduledTestResult
{}
if
err
:=
rows
.
Scan
(
&
r
.
ID
,
&
r
.
PlanID
,
&
r
.
Status
,
&
r
.
ResponseText
,
&
r
.
ErrorMessage
,
&
r
.
LatencyMs
,
&
r
.
StartedAt
,
&
r
.
FinishedAt
,
&
r
.
CreatedAt
,
);
err
!=
nil
{
return
nil
,
err
}
results
=
append
(
results
,
r
)
}
return
results
,
rows
.
Err
()
}
func
(
r
*
scheduledTestResultRepository
)
PruneOldResults
(
ctx
context
.
Context
,
planID
int64
,
keepCount
int
)
error
{
_
,
err
:=
r
.
db
.
ExecContext
(
ctx
,
`
DELETE FROM scheduled_test_results
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY plan_id ORDER BY created_at DESC) AS rn
FROM scheduled_test_results
WHERE plan_id = $1
) ranked
WHERE rn > $2
)
`
,
planID
,
keepCount
)
return
err
}
// --- scan helpers ---
type
scannable
interface
{
Scan
(
dest
...
any
)
error
}
func
scanPlan
(
row
scannable
)
(
*
service
.
ScheduledTestPlan
,
error
)
{
p
:=
&
service
.
ScheduledTestPlan
{}
if
err
:=
row
.
Scan
(
&
p
.
ID
,
&
p
.
AccountID
,
&
p
.
ModelID
,
&
p
.
CronExpression
,
&
p
.
Enabled
,
&
p
.
MaxResults
,
&
p
.
LastRunAt
,
&
p
.
NextRunAt
,
&
p
.
CreatedAt
,
&
p
.
UpdatedAt
,
);
err
!=
nil
{
return
nil
,
err
}
return
p
,
nil
}
func
scanPlans
(
rows
*
sql
.
Rows
)
([]
*
service
.
ScheduledTestPlan
,
error
)
{
var
plans
[]
*
service
.
ScheduledTestPlan
for
rows
.
Next
()
{
p
,
err
:=
scanPlan
(
rows
)
if
err
!=
nil
{
return
nil
,
err
}
plans
=
append
(
plans
,
p
)
}
return
plans
,
rows
.
Err
()
}
backend/internal/repository/wire.go
View file @
642432cf
...
...
@@ -53,7 +53,9 @@ var ProviderSet = wire.NewSet(
NewAPIKeyRepository
,
NewGroupRepository
,
NewAccountRepository
,
NewSoraAccountRepository
,
// Sora 账号扩展表仓储
NewSoraAccountRepository
,
// Sora 账号扩展表仓储
NewScheduledTestPlanRepository
,
// 定时测试计划仓储
NewScheduledTestResultRepository
,
// 定时测试结果仓储
NewProxyRepository
,
NewRedeemCodeRepository
,
NewPromoCodeRepository
,
...
...
backend/internal/server/routes/admin.go
View file @
642432cf
...
...
@@ -78,6 +78,9 @@ func RegisterAdminRoutes(
// API Key 管理
registerAdminAPIKeyRoutes
(
admin
,
h
)
// 定时测试计划
registerScheduledTestRoutes
(
admin
,
h
)
}
}
...
...
@@ -478,6 +481,18 @@ func registerUserAttributeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
func
registerScheduledTestRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
plans
:=
admin
.
Group
(
"/scheduled-test-plans"
)
{
plans
.
POST
(
""
,
h
.
Admin
.
ScheduledTest
.
Create
)
plans
.
PUT
(
"/:id"
,
h
.
Admin
.
ScheduledTest
.
Update
)
plans
.
DELETE
(
"/:id"
,
h
.
Admin
.
ScheduledTest
.
Delete
)
plans
.
GET
(
"/:id/results"
,
h
.
Admin
.
ScheduledTest
.
ListResults
)
}
// Nested under accounts
admin
.
GET
(
"/accounts/:id/scheduled-test-plans"
,
h
.
Admin
.
ScheduledTest
.
ListByAccount
)
}
func
registerErrorPassthroughRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
rules
:=
admin
.
Group
(
"/error-passthrough-rules"
)
{
...
...
backend/internal/service/account_test_service.go
View file @
642432cf
...
...
@@ -12,6 +12,7 @@ import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
...
...
@@ -1560,3 +1561,62 @@ func (s *AccountTestService) sendErrorAndEnd(c *gin.Context, errorMsg string) er
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"error"
,
Error
:
errorMsg
})
return
fmt
.
Errorf
(
"%s"
,
errorMsg
)
}
// RunTestBackground executes an account test in-memory (no real HTTP client),
// capturing SSE output via httptest.NewRecorder, then parses the result.
func
(
s
*
AccountTestService
)
RunTestBackground
(
ctx
context
.
Context
,
accountID
int64
,
modelID
string
)
(
*
ScheduledTestResult
,
error
)
{
startedAt
:=
time
.
Now
()
w
:=
httptest
.
NewRecorder
()
ginCtx
,
_
:=
gin
.
CreateTestContext
(
w
)
ginCtx
.
Request
=
(
&
http
.
Request
{})
.
WithContext
(
ctx
)
testErr
:=
s
.
TestAccountConnection
(
ginCtx
,
accountID
,
modelID
)
finishedAt
:=
time
.
Now
()
body
:=
w
.
Body
.
String
()
responseText
,
errMsg
:=
parseTestSSEOutput
(
body
)
status
:=
"success"
if
testErr
!=
nil
||
errMsg
!=
""
{
status
=
"failed"
if
errMsg
==
""
&&
testErr
!=
nil
{
errMsg
=
testErr
.
Error
()
}
}
return
&
ScheduledTestResult
{
Status
:
status
,
ResponseText
:
responseText
,
ErrorMessage
:
errMsg
,
LatencyMs
:
finishedAt
.
Sub
(
startedAt
)
.
Milliseconds
(),
StartedAt
:
startedAt
,
FinishedAt
:
finishedAt
,
},
nil
}
// parseTestSSEOutput extracts response text and error message from captured SSE output.
func
parseTestSSEOutput
(
body
string
)
(
responseText
,
errMsg
string
)
{
var
texts
[]
string
for
_
,
line
:=
range
strings
.
Split
(
body
,
"
\n
"
)
{
line
=
strings
.
TrimSpace
(
line
)
if
!
strings
.
HasPrefix
(
line
,
"data: "
)
{
continue
}
jsonStr
:=
strings
.
TrimPrefix
(
line
,
"data: "
)
var
event
TestEvent
if
err
:=
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
event
);
err
!=
nil
{
continue
}
switch
event
.
Type
{
case
"content"
:
if
event
.
Text
!=
""
{
texts
=
append
(
texts
,
event
.
Text
)
}
case
"error"
:
errMsg
=
event
.
Error
}
}
responseText
=
strings
.
Join
(
texts
,
""
)
return
}
backend/internal/service/scheduled_test_port.go
0 → 100644
View file @
642432cf
package
service
import
(
"context"
"time"
)
// ScheduledTestPlan represents a scheduled test plan domain model.
type
ScheduledTestPlan
struct
{
ID
int64
`json:"id"`
AccountID
int64
`json:"account_id"`
ModelID
string
`json:"model_id"`
CronExpression
string
`json:"cron_expression"`
Enabled
bool
`json:"enabled"`
MaxResults
int
`json:"max_results"`
LastRunAt
*
time
.
Time
`json:"last_run_at"`
NextRunAt
*
time
.
Time
`json:"next_run_at"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
// ScheduledTestResult represents a single test execution result.
type
ScheduledTestResult
struct
{
ID
int64
`json:"id"`
PlanID
int64
`json:"plan_id"`
Status
string
`json:"status"`
ResponseText
string
`json:"response_text"`
ErrorMessage
string
`json:"error_message"`
LatencyMs
int64
`json:"latency_ms"`
StartedAt
time
.
Time
`json:"started_at"`
FinishedAt
time
.
Time
`json:"finished_at"`
CreatedAt
time
.
Time
`json:"created_at"`
}
// ScheduledTestPlanRepository defines the data access interface for test plans.
type
ScheduledTestPlanRepository
interface
{
Create
(
ctx
context
.
Context
,
plan
*
ScheduledTestPlan
)
(
*
ScheduledTestPlan
,
error
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
ScheduledTestPlan
,
error
)
ListByAccountID
(
ctx
context
.
Context
,
accountID
int64
)
([]
*
ScheduledTestPlan
,
error
)
ListDue
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
*
ScheduledTestPlan
,
error
)
Update
(
ctx
context
.
Context
,
plan
*
ScheduledTestPlan
)
(
*
ScheduledTestPlan
,
error
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
UpdateAfterRun
(
ctx
context
.
Context
,
id
int64
,
lastRunAt
time
.
Time
,
nextRunAt
time
.
Time
)
error
}
// ScheduledTestResultRepository defines the data access interface for test results.
type
ScheduledTestResultRepository
interface
{
Create
(
ctx
context
.
Context
,
result
*
ScheduledTestResult
)
(
*
ScheduledTestResult
,
error
)
ListByPlanID
(
ctx
context
.
Context
,
planID
int64
,
limit
int
)
([]
*
ScheduledTestResult
,
error
)
PruneOldResults
(
ctx
context
.
Context
,
planID
int64
,
keepCount
int
)
error
}
backend/internal/service/scheduled_test_runner_service.go
0 → 100644
View file @
642432cf
package
service
import
(
"context"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/robfig/cron/v3"
)
const
scheduledTestDefaultMaxWorkers
=
10
// ScheduledTestRunnerService periodically scans due test plans and executes them.
type
ScheduledTestRunnerService
struct
{
planRepo
ScheduledTestPlanRepository
scheduledSvc
*
ScheduledTestService
accountTestSvc
*
AccountTestService
cfg
*
config
.
Config
cron
*
cron
.
Cron
startOnce
sync
.
Once
stopOnce
sync
.
Once
}
// NewScheduledTestRunnerService creates a new runner.
func
NewScheduledTestRunnerService
(
planRepo
ScheduledTestPlanRepository
,
scheduledSvc
*
ScheduledTestService
,
accountTestSvc
*
AccountTestService
,
cfg
*
config
.
Config
,
)
*
ScheduledTestRunnerService
{
return
&
ScheduledTestRunnerService
{
planRepo
:
planRepo
,
scheduledSvc
:
scheduledSvc
,
accountTestSvc
:
accountTestSvc
,
cfg
:
cfg
,
}
}
// Start begins the cron ticker (every minute).
func
(
s
*
ScheduledTestRunnerService
)
Start
()
{
if
s
==
nil
{
return
}
s
.
startOnce
.
Do
(
func
()
{
loc
:=
time
.
Local
if
s
.
cfg
!=
nil
{
if
parsed
,
err
:=
time
.
LoadLocation
(
s
.
cfg
.
Timezone
);
err
==
nil
&&
parsed
!=
nil
{
loc
=
parsed
}
}
c
:=
cron
.
New
(
cron
.
WithParser
(
scheduledTestCronParser
),
cron
.
WithLocation
(
loc
))
_
,
err
:=
c
.
AddFunc
(
"* * * * *"
,
func
()
{
s
.
runScheduled
()
})
if
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] not started (invalid schedule): %v"
,
err
)
return
}
s
.
cron
=
c
s
.
cron
.
Start
()
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] started (tick=every minute)"
)
})
}
// Stop gracefully shuts down the cron scheduler.
func
(
s
*
ScheduledTestRunnerService
)
Stop
()
{
if
s
==
nil
{
return
}
s
.
stopOnce
.
Do
(
func
()
{
if
s
.
cron
!=
nil
{
ctx
:=
s
.
cron
.
Stop
()
select
{
case
<-
ctx
.
Done
()
:
case
<-
time
.
After
(
3
*
time
.
Second
)
:
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] cron stop timed out"
)
}
}
})
}
func
(
s
*
ScheduledTestRunnerService
)
runScheduled
()
{
// Delay 10s so execution lands at ~:10 of each minute instead of :00.
time
.
Sleep
(
10
*
time
.
Second
)
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Minute
)
defer
cancel
()
now
:=
time
.
Now
()
plans
,
err
:=
s
.
planRepo
.
ListDue
(
ctx
,
now
)
if
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] ListDue error: %v"
,
err
)
return
}
if
len
(
plans
)
==
0
{
return
}
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] found %d due plans"
,
len
(
plans
))
sem
:=
make
(
chan
struct
{},
scheduledTestDefaultMaxWorkers
)
var
wg
sync
.
WaitGroup
for
_
,
plan
:=
range
plans
{
sem
<-
struct
{}{}
wg
.
Add
(
1
)
go
func
(
p
*
ScheduledTestPlan
)
{
defer
wg
.
Done
()
defer
func
()
{
<-
sem
}()
s
.
runOnePlan
(
ctx
,
p
)
}(
plan
)
}
wg
.
Wait
()
}
func
(
s
*
ScheduledTestRunnerService
)
runOnePlan
(
ctx
context
.
Context
,
plan
*
ScheduledTestPlan
)
{
result
,
err
:=
s
.
accountTestSvc
.
RunTestBackground
(
ctx
,
plan
.
AccountID
,
plan
.
ModelID
)
if
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] plan=%d RunTestBackground error: %v"
,
plan
.
ID
,
err
)
return
}
if
err
:=
s
.
scheduledSvc
.
SaveResult
(
ctx
,
plan
.
ID
,
plan
.
MaxResults
,
result
);
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] plan=%d SaveResult error: %v"
,
plan
.
ID
,
err
)
}
nextRun
,
err
:=
computeNextRun
(
plan
.
CronExpression
,
time
.
Now
())
if
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] plan=%d computeNextRun error: %v"
,
plan
.
ID
,
err
)
return
}
if
err
:=
s
.
planRepo
.
UpdateAfterRun
(
ctx
,
plan
.
ID
,
time
.
Now
(),
nextRun
);
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.scheduled_test_runner"
,
"[ScheduledTestRunner] plan=%d UpdateAfterRun error: %v"
,
plan
.
ID
,
err
)
}
}
backend/internal/service/scheduled_test_service.go
0 → 100644
View file @
642432cf
package
service
import
(
"context"
"fmt"
"time"
"github.com/robfig/cron/v3"
)
var
scheduledTestCronParser
=
cron
.
NewParser
(
cron
.
Minute
|
cron
.
Hour
|
cron
.
Dom
|
cron
.
Month
|
cron
.
Dow
)
// ScheduledTestService provides CRUD operations for scheduled test plans and results.
type
ScheduledTestService
struct
{
planRepo
ScheduledTestPlanRepository
resultRepo
ScheduledTestResultRepository
}
// NewScheduledTestService creates a new ScheduledTestService.
func
NewScheduledTestService
(
planRepo
ScheduledTestPlanRepository
,
resultRepo
ScheduledTestResultRepository
,
)
*
ScheduledTestService
{
return
&
ScheduledTestService
{
planRepo
:
planRepo
,
resultRepo
:
resultRepo
,
}
}
// CreatePlan validates the cron expression, computes next_run_at, and persists the plan.
func
(
s
*
ScheduledTestService
)
CreatePlan
(
ctx
context
.
Context
,
plan
*
ScheduledTestPlan
)
(
*
ScheduledTestPlan
,
error
)
{
nextRun
,
err
:=
computeNextRun
(
plan
.
CronExpression
,
time
.
Now
())
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid cron expression: %w"
,
err
)
}
plan
.
NextRunAt
=
&
nextRun
if
plan
.
MaxResults
<=
0
{
plan
.
MaxResults
=
50
}
return
s
.
planRepo
.
Create
(
ctx
,
plan
)
}
// GetPlan retrieves a plan by ID.
func
(
s
*
ScheduledTestService
)
GetPlan
(
ctx
context
.
Context
,
id
int64
)
(
*
ScheduledTestPlan
,
error
)
{
return
s
.
planRepo
.
GetByID
(
ctx
,
id
)
}
// ListPlansByAccount returns all plans for a given account.
func
(
s
*
ScheduledTestService
)
ListPlansByAccount
(
ctx
context
.
Context
,
accountID
int64
)
([]
*
ScheduledTestPlan
,
error
)
{
return
s
.
planRepo
.
ListByAccountID
(
ctx
,
accountID
)
}
// UpdatePlan validates cron and updates the plan.
func
(
s
*
ScheduledTestService
)
UpdatePlan
(
ctx
context
.
Context
,
plan
*
ScheduledTestPlan
)
(
*
ScheduledTestPlan
,
error
)
{
nextRun
,
err
:=
computeNextRun
(
plan
.
CronExpression
,
time
.
Now
())
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid cron expression: %w"
,
err
)
}
plan
.
NextRunAt
=
&
nextRun
return
s
.
planRepo
.
Update
(
ctx
,
plan
)
}
// DeletePlan removes a plan and its results (via CASCADE).
func
(
s
*
ScheduledTestService
)
DeletePlan
(
ctx
context
.
Context
,
id
int64
)
error
{
return
s
.
planRepo
.
Delete
(
ctx
,
id
)
}
// ListResults returns the most recent results for a plan.
func
(
s
*
ScheduledTestService
)
ListResults
(
ctx
context
.
Context
,
planID
int64
,
limit
int
)
([]
*
ScheduledTestResult
,
error
)
{
if
limit
<=
0
{
limit
=
50
}
return
s
.
resultRepo
.
ListByPlanID
(
ctx
,
planID
,
limit
)
}
// SaveResult inserts a result and prunes old entries beyond maxResults.
func
(
s
*
ScheduledTestService
)
SaveResult
(
ctx
context
.
Context
,
planID
int64
,
maxResults
int
,
result
*
ScheduledTestResult
)
error
{
result
.
PlanID
=
planID
if
_
,
err
:=
s
.
resultRepo
.
Create
(
ctx
,
result
);
err
!=
nil
{
return
err
}
return
s
.
resultRepo
.
PruneOldResults
(
ctx
,
planID
,
maxResults
)
}
func
computeNextRun
(
cronExpr
string
,
from
time
.
Time
)
(
time
.
Time
,
error
)
{
sched
,
err
:=
scheduledTestCronParser
.
Parse
(
cronExpr
)
if
err
!=
nil
{
return
time
.
Time
{},
err
}
return
sched
.
Next
(
from
),
nil
}
backend/internal/service/wire.go
View file @
642432cf
...
...
@@ -274,6 +274,26 @@ func ProvideIdempotencyCleanupService(repo IdempotencyRepository, cfg *config.Co
return
svc
}
// ProvideScheduledTestService creates ScheduledTestService.
func
ProvideScheduledTestService
(
planRepo
ScheduledTestPlanRepository
,
resultRepo
ScheduledTestResultRepository
,
)
*
ScheduledTestService
{
return
NewScheduledTestService
(
planRepo
,
resultRepo
)
}
// ProvideScheduledTestRunnerService creates and starts ScheduledTestRunnerService.
func
ProvideScheduledTestRunnerService
(
planRepo
ScheduledTestPlanRepository
,
scheduledSvc
*
ScheduledTestService
,
accountTestSvc
*
AccountTestService
,
cfg
*
config
.
Config
,
)
*
ScheduledTestRunnerService
{
svc
:=
NewScheduledTestRunnerService
(
planRepo
,
scheduledSvc
,
accountTestSvc
,
cfg
)
svc
.
Start
()
return
svc
}
// ProvideOpsScheduledReportService creates and starts OpsScheduledReportService.
func
ProvideOpsScheduledReportService
(
opsService
*
OpsService
,
...
...
@@ -380,4 +400,6 @@ var ProviderSet = wire.NewSet(
ProvideIdempotencyCoordinator
,
ProvideSystemOperationLockService
,
ProvideIdempotencyCleanupService
,
ProvideScheduledTestService
,
ProvideScheduledTestRunnerService
,
)
backend/migrations/066_add_scheduled_test_tables.sql
0 → 100644
View file @
642432cf
-- 066_add_scheduled_test_tables.sql
-- Scheduled account test plans and results
CREATE
TABLE
IF
NOT
EXISTS
scheduled_test_plans
(
id
BIGSERIAL
PRIMARY
KEY
,
account_id
BIGINT
NOT
NULL
REFERENCES
accounts
(
id
)
ON
DELETE
CASCADE
,
model_id
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
cron_expression
VARCHAR
(
100
)
NOT
NULL
DEFAULT
'*/30 * * * *'
,
enabled
BOOLEAN
NOT
NULL
DEFAULT
true
,
max_results
INT
NOT
NULL
DEFAULT
50
,
last_run_at
TIMESTAMPTZ
,
next_run_at
TIMESTAMPTZ
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_stp_account_id
ON
scheduled_test_plans
(
account_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_stp_enabled_next_run
ON
scheduled_test_plans
(
enabled
,
next_run_at
)
WHERE
enabled
=
true
;
CREATE
TABLE
IF
NOT
EXISTS
scheduled_test_results
(
id
BIGSERIAL
PRIMARY
KEY
,
plan_id
BIGINT
NOT
NULL
REFERENCES
scheduled_test_plans
(
id
)
ON
DELETE
CASCADE
,
status
VARCHAR
(
20
)
NOT
NULL
DEFAULT
'success'
,
response_text
TEXT
NOT
NULL
DEFAULT
''
,
error_message
TEXT
NOT
NULL
DEFAULT
''
,
latency_ms
BIGINT
NOT
NULL
DEFAULT
0
,
started_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
finished_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_str_plan_created
ON
scheduled_test_results
(
plan_id
,
created_at
DESC
);
frontend/src/api/admin/index.ts
View file @
642432cf
...
...
@@ -22,6 +22,7 @@ import opsAPI from './ops'
import
errorPassthroughAPI
from
'
./errorPassthrough
'
import
dataManagementAPI
from
'
./dataManagement
'
import
apiKeysAPI
from
'
./apiKeys
'
import
scheduledTestsAPI
from
'
./scheduledTests
'
/**
* Unified admin API object for convenient access
...
...
@@ -45,7 +46,8 @@ export const adminAPI = {
ops
:
opsAPI
,
errorPassthrough
:
errorPassthroughAPI
,
dataManagement
:
dataManagementAPI
,
apiKeys
:
apiKeysAPI
apiKeys
:
apiKeysAPI
,
scheduledTests
:
scheduledTestsAPI
}
export
{
...
...
@@ -67,7 +69,8 @@ export {
opsAPI
,
errorPassthroughAPI
,
dataManagementAPI
,
apiKeysAPI
apiKeysAPI
,
scheduledTestsAPI
}
export
default
adminAPI
...
...
frontend/src/api/admin/scheduledTests.ts
0 → 100644
View file @
642432cf
/**
* Admin Scheduled Tests API endpoints
* Handles scheduled test plan management for account connectivity monitoring
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
ScheduledTestPlan
,
ScheduledTestResult
,
CreateScheduledTestPlanRequest
,
UpdateScheduledTestPlanRequest
}
from
'
@/types
'
/**
* List all scheduled test plans for an account
* @param accountId - Account ID
* @returns List of scheduled test plans
*/
export
async
function
listByAccount
(
accountId
:
number
):
Promise
<
ScheduledTestPlan
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
ScheduledTestPlan
[]
>
(
`/admin/accounts/
${
accountId
}
/scheduled-test-plans`
)
return
data
??
[]
}
/**
* Create a new scheduled test plan
* @param req - Plan creation request
* @returns Created plan
*/
export
async
function
create
(
req
:
CreateScheduledTestPlanRequest
):
Promise
<
ScheduledTestPlan
>
{
const
{
data
}
=
await
apiClient
.
post
<
ScheduledTestPlan
>
(
'
/admin/scheduled-test-plans
'
,
req
)
return
data
}
/**
* Update an existing scheduled test plan
* @param id - Plan ID
* @param req - Fields to update
* @returns Updated plan
*/
export
async
function
update
(
id
:
number
,
req
:
UpdateScheduledTestPlanRequest
):
Promise
<
ScheduledTestPlan
>
{
const
{
data
}
=
await
apiClient
.
put
<
ScheduledTestPlan
>
(
`/admin/scheduled-test-plans/
${
id
}
`
,
req
)
return
data
}
/**
* Delete a scheduled test plan
* @param id - Plan ID
*/
export
async
function
deletePlan
(
id
:
number
):
Promise
<
void
>
{
await
apiClient
.
delete
(
`/admin/scheduled-test-plans/
${
id
}
`
)
}
/**
* List test results for a plan
* @param planId - Plan ID
* @param limit - Optional max number of results to return
* @returns List of test results
*/
export
async
function
listResults
(
planId
:
number
,
limit
?:
number
):
Promise
<
ScheduledTestResult
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
ScheduledTestResult
[]
>
(
`/admin/scheduled-test-plans/
${
planId
}
/results`
,
{
params
:
limit
?
{
limit
}
:
undefined
}
)
return
data
??
[]
}
export
const
scheduledTestsAPI
=
{
listByAccount
,
create
,
update
,
delete
:
deletePlan
,
listResults
}
export
default
scheduledTestsAPI
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
642432cf
...
...
@@ -18,6 +18,10 @@
<Icon
name=
"chart"
size=
"sm"
class=
"text-indigo-500"
/>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
</button>
<button
@
click=
"$emit('schedule', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"clock"
size=
"sm"
class=
"text-orange-500"
/>
{{
t
(
'
admin.scheduledTests.schedule
'
)
}}
</button>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"link"
size=
"sm"
/>
...
...
@@ -51,7 +55,7 @@ import { Icon } from '@/components/icons'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
schedule
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
{
if
(
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
{
...
...
frontend/src/components/admin/account/ScheduledTestsPanel.vue
0 → 100644
View file @
642432cf
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.scheduledTests.title')"
width=
"wide"
@
close=
"emit('close')"
>
<div
class=
"space-y-4"
>
<!-- Add Plan Button -->
<div
class=
"flex items-center justify-between"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.title
'
)
}}
</p>
<button
@
click=
"showAddForm = !showAddForm"
class=
"btn btn-primary flex items-center gap-1.5 text-sm"
>
<Icon
name=
"plus"
size=
"sm"
:stroke-width=
"2"
/>
{{
t
(
'
admin.scheduledTests.addPlan
'
)
}}
</button>
</div>
<!-- Add Plan Form -->
<div
v-if=
"showAddForm"
class=
"rounded-xl border border-primary-200 bg-primary-50/50 p-4 dark:border-primary-800 dark:bg-primary-900/20"
>
<div
class=
"mb-3 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.scheduledTests.addPlan
'
)
}}
</div>
<div
class=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.model
'
)
}}
</label>
<Select
v-model=
"newPlan.model_id"
:options=
"modelOptions"
:placeholder=
"t('admin.scheduledTests.model')"
:searchable=
"modelOptions.length > 5"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.cronExpression
'
)
}}
</label>
<Input
v-model=
"newPlan.cron_expression"
:placeholder=
"'*/30 * * * *'"
:hint=
"t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.maxResults
'
)
}}
</label>
<Input
v-model=
"newPlan.max_results"
type=
"number"
placeholder=
"100"
/>
</div>
<div
class=
"flex items-end"
>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<Toggle
v-model=
"newPlan.enabled"
/>
{{
t
(
'
admin.scheduledTests.enabled
'
)
}}
</label>
</div>
</div>
<div
class=
"mt-3 flex justify-end gap-2"
>
<button
@
click=
"showAddForm = false; resetNewPlan()"
class=
"rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleCreate"
:disabled=
"!newPlan.model_id || !newPlan.cron_expression || creating"
class=
"flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon
v-if=
"creating"
name=
"refresh"
size=
"sm"
class=
"animate-spin"
:stroke-width=
"2"
/>
{{
t
(
'
common.save
'
)
}}
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-8"
>
<Icon
name=
"refresh"
size=
"md"
class=
"animate-spin text-gray-400"
:stroke-width=
"2"
/>
<span
class=
"ml-2 text-sm text-gray-500"
>
{{
t
(
'
common.loading
'
)
}}
...
</span>
</div>
<!-- Empty State -->
<div
v-else-if=
"plans.length === 0"
class=
"rounded-xl border border-dashed border-gray-300 py-10 text-center dark:border-dark-600"
>
<Icon
name=
"calendar"
size=
"lg"
class=
"mx-auto mb-2 text-gray-400"
:stroke-width=
"1.5"
/>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.noPlans
'
)
}}
</p>
</div>
<!-- Plans List -->
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"plan in plans"
:key=
"plan.id"
class=
"rounded-xl border border-gray-200 bg-white transition-all dark:border-dark-600 dark:bg-dark-800"
>
<!-- Plan Header -->
<div
class=
"flex cursor-pointer items-center justify-between px-4 py-3"
@
click=
"toggleExpand(plan.id)"
>
<div
class=
"flex flex-1 items-center gap-4"
>
<!-- Model -->
<div
class=
"min-w-0"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-gray-100"
>
{{
plan
.
model_id
}}
</div>
<div
class=
"mt-0.5 font-mono text-xs text-gray-500 dark:text-gray-400"
>
{{
plan
.
cron_expression
}}
</div>
</div>
<!-- Enabled Toggle -->
<div
class=
"flex items-center gap-1.5"
@
click.stop
>
<Toggle
:model-value=
"plan.enabled"
@
update:model-value=
"(val: boolean) => handleToggleEnabled(plan, val)"
/>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
plan
.
enabled
?
t
(
'
admin.scheduledTests.enabled
'
)
:
''
}}
</span>
</div>
</div>
<div
class=
"flex items-center gap-3"
>
<!-- Last Run -->
<div
v-if=
"plan.last_run_at"
class=
"hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block"
>
<div>
{{
t
(
'
admin.scheduledTests.lastRun
'
)
}}
</div>
<div>
{{
formatDateTime
(
plan
.
last_run_at
)
}}
</div>
</div>
<!-- Next Run -->
<div
v-if=
"plan.next_run_at"
class=
"hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block"
>
<div>
{{
t
(
'
admin.scheduledTests.nextRun
'
)
}}
</div>
<div>
{{
formatDateTime
(
plan
.
next_run_at
)
}}
</div>
</div>
<!-- Actions -->
<div
class=
"flex items-center gap-1"
@
click.stop
>
<button
@
click=
"startEdit(plan)"
class=
"rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
:title=
"t('admin.scheduledTests.editPlan')"
>
<Icon
name=
"edit"
size=
"sm"
:stroke-width=
"2"
/>
</button>
<button
@
click=
"confirmDeletePlan(plan)"
class=
"rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
:title=
"t('admin.scheduledTests.deletePlan')"
>
<Icon
name=
"trash"
size=
"sm"
:stroke-width=
"2"
/>
</button>
</div>
<!-- Expand indicator -->
<Icon
name=
"chevronDown"
size=
"sm"
:class=
"[
'text-gray-400 transition-transform duration-200',
expandedPlanId === plan.id ? 'rotate-180' : ''
]"
/>
</div>
</div>
<!-- Edit Form -->
<div
v-if=
"editingPlanId === plan.id"
class=
"border-t border-blue-100 bg-blue-50/50 px-4 py-3 dark:border-blue-900 dark:bg-blue-900/10"
@
click.stop
>
<div
class=
"mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.editPlan
'
)
}}
</div>
<div
class=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.model
'
)
}}
</label>
<Select
v-model=
"editForm.model_id"
:options=
"modelOptions"
:placeholder=
"t('admin.scheduledTests.model')"
:searchable=
"modelOptions.length > 5"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.cronExpression
'
)
}}
</label>
<Input
v-model=
"editForm.cron_expression"
:placeholder=
"'*/30 * * * *'"
:hint=
"t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.maxResults
'
)
}}
</label>
<Input
v-model=
"editForm.max_results"
type=
"number"
placeholder=
"100"
/>
</div>
<div
class=
"flex items-end"
>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<Toggle
v-model=
"editForm.enabled"
/>
{{
t
(
'
admin.scheduledTests.enabled
'
)
}}
</label>
</div>
</div>
<div
class=
"mt-3 flex justify-end gap-2"
>
<button
@
click=
"cancelEdit"
class=
"rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleEdit"
:disabled=
"!editForm.model_id || !editForm.cron_expression || updating"
class=
"flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon
v-if=
"updating"
name=
"refresh"
size=
"sm"
class=
"animate-spin"
:stroke-width=
"2"
/>
{{
t
(
'
common.save
'
)
}}
</button>
</div>
</div>
<!-- Expanded Results Section -->
<div
v-if=
"expandedPlanId === plan.id"
class=
"border-t border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div
class=
"mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.results
'
)
}}
</div>
<!-- Results Loading -->
<div
v-if=
"loadingResults"
class=
"flex items-center justify-center py-4"
>
<Icon
name=
"refresh"
size=
"sm"
class=
"animate-spin text-gray-400"
:stroke-width=
"2"
/>
<span
class=
"ml-2 text-xs text-gray-500"
>
{{
t
(
'
common.loading
'
)
}}
...
</span>
</div>
<!-- No Results -->
<div
v-else-if=
"results.length === 0"
class=
"py-4 text-center text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.scheduledTests.noResults
'
)
}}
</div>
<!-- Results List -->
<div
v-else
class=
"max-h-64 space-y-2 overflow-y-auto"
>
<div
v-for=
"result in results"
:key=
"result.id"
class=
"rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"flex items-center justify-between"
>
<div
class=
"flex items-center gap-2"
>
<!-- Status Badge -->
<span
:class=
"[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
result.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: result.status === 'running'
? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
]"
>
{{
result
.
status
===
'
success
'
?
t
(
'
admin.scheduledTests.success
'
)
:
result
.
status
===
'
running
'
?
t
(
'
admin.scheduledTests.running
'
)
:
t
(
'
admin.scheduledTests.failed
'
)
}}
</span>
<!-- Latency -->
<span
v-if=
"result.latency_ms > 0"
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
result
.
latency_ms
}}
ms
</span>
</div>
<!-- Started At -->
<span
class=
"text-xs text-gray-400"
>
{{
formatDateTime
(
result
.
started_at
)
}}
</span>
</div>
<!-- Response / Error (collapsible) -->
<div
v-if=
"result.error_message"
class=
"mt-2"
>
<div
class=
"cursor-pointer text-xs font-medium text-red-600 dark:text-red-400"
@
click=
"toggleResultDetail(result.id)"
>
{{
t
(
'
admin.scheduledTests.errorMessage
'
)
}}
<Icon
name=
"chevronDown"
size=
"sm"
:class=
"[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if=
"expandedResultIds.has(result.id)"
class=
"mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300"
>
{{
result
.
error_message
}}
</pre>
</div>
<div
v-else-if=
"result.response_text"
class=
"mt-2"
>
<div
class=
"cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400"
@
click=
"toggleResultDetail(result.id)"
>
{{
t
(
'
admin.scheduledTests.responseText
'
)
}}
<Icon
name=
"chevronDown"
size=
"sm"
:class=
"[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if=
"expandedResultIds.has(result.id)"
class=
"mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-gray-100 p-2 text-xs text-gray-700 dark:bg-dark-800 dark:text-gray-300"
>
{{
result
.
response_text
}}
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation -->
<ConfirmDialog
:show=
"showDeleteConfirm"
:title=
"t('admin.scheduledTests.deletePlan')"
:message=
"t('admin.scheduledTests.confirmDelete')"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"handleDelete"
@
cancel=
"showDeleteConfirm = false"
/>
</BaseDialog>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
,
{
type
SelectOption
}
from
'
@/components/common/Select.vue
'
import
Input
from
'
@/components/common/Input.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
{
Icon
}
from
'
@/components/icons
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
ScheduledTestPlan
,
ScheduledTestResult
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
props
=
defineProps
<
{
show
:
boolean
accountId
:
number
|
null
modelOptions
:
SelectOption
[]
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
// State
const
loading
=
ref
(
false
)
const
creating
=
ref
(
false
)
const
loadingResults
=
ref
(
false
)
const
plans
=
ref
<
ScheduledTestPlan
[]
>
([])
const
results
=
ref
<
ScheduledTestResult
[]
>
([])
const
expandedPlanId
=
ref
<
number
|
null
>
(
null
)
const
expandedResultIds
=
reactive
(
new
Set
<
number
>
())
const
showAddForm
=
ref
(
false
)
const
showDeleteConfirm
=
ref
(
false
)
const
deletingPlan
=
ref
<
ScheduledTestPlan
|
null
>
(
null
)
const
editingPlanId
=
ref
<
number
|
null
>
(
null
)
const
updating
=
ref
(
false
)
const
editForm
=
reactive
({
model_id
:
''
as
string
,
cron_expression
:
''
as
string
,
max_results
:
'
100
'
as
string
,
enabled
:
true
})
const
newPlan
=
reactive
({
model_id
:
''
as
string
,
cron_expression
:
''
as
string
,
max_results
:
'
100
'
as
string
,
enabled
:
true
})
const
resetNewPlan
=
()
=>
{
newPlan
.
model_id
=
''
newPlan
.
cron_expression
=
''
newPlan
.
max_results
=
'
100
'
newPlan
.
enabled
=
true
}
// Load plans when dialog opens
watch
(
()
=>
props
.
show
,
async
(
visible
)
=>
{
if
(
visible
&&
props
.
accountId
)
{
await
loadPlans
()
}
else
{
plans
.
value
=
[]
results
.
value
=
[]
expandedPlanId
.
value
=
null
expandedResultIds
.
clear
()
showAddForm
.
value
=
false
showDeleteConfirm
.
value
=
false
}
}
)
const
loadPlans
=
async
()
=>
{
if
(
!
props
.
accountId
)
return
loading
.
value
=
true
try
{
plans
.
value
=
await
adminAPI
.
scheduledTests
.
listByAccount
(
props
.
accountId
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to load plans
'
)
}
finally
{
loading
.
value
=
false
}
}
const
handleCreate
=
async
()
=>
{
if
(
!
props
.
accountId
||
!
newPlan
.
model_id
||
!
newPlan
.
cron_expression
)
return
creating
.
value
=
true
try
{
const
maxResults
=
Number
(
newPlan
.
max_results
)
||
100
await
adminAPI
.
scheduledTests
.
create
({
account_id
:
props
.
accountId
,
model_id
:
newPlan
.
model_id
,
cron_expression
:
newPlan
.
cron_expression
,
enabled
:
newPlan
.
enabled
,
max_results
:
maxResults
})
appStore
.
showSuccess
(
t
(
'
admin.scheduledTests.createSuccess
'
))
showAddForm
.
value
=
false
resetNewPlan
()
await
loadPlans
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to create plan
'
)
}
finally
{
creating
.
value
=
false
}
}
const
handleToggleEnabled
=
async
(
plan
:
ScheduledTestPlan
,
enabled
:
boolean
)
=>
{
try
{
const
updated
=
await
adminAPI
.
scheduledTests
.
update
(
plan
.
id
,
{
enabled
})
const
index
=
plans
.
value
.
findIndex
((
p
)
=>
p
.
id
===
plan
.
id
)
if
(
index
!==
-
1
)
{
plans
.
value
[
index
]
=
updated
}
appStore
.
showSuccess
(
t
(
'
admin.scheduledTests.updateSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to update plan
'
)
}
}
const
startEdit
=
(
plan
:
ScheduledTestPlan
)
=>
{
editingPlanId
.
value
=
plan
.
id
editForm
.
model_id
=
plan
.
model_id
editForm
.
cron_expression
=
plan
.
cron_expression
editForm
.
max_results
=
String
(
plan
.
max_results
)
editForm
.
enabled
=
plan
.
enabled
}
const
cancelEdit
=
()
=>
{
editingPlanId
.
value
=
null
}
const
handleEdit
=
async
()
=>
{
if
(
!
editingPlanId
.
value
||
!
editForm
.
model_id
||
!
editForm
.
cron_expression
)
return
updating
.
value
=
true
try
{
const
updated
=
await
adminAPI
.
scheduledTests
.
update
(
editingPlanId
.
value
,
{
model_id
:
editForm
.
model_id
,
cron_expression
:
editForm
.
cron_expression
,
max_results
:
Number
(
editForm
.
max_results
)
||
100
,
enabled
:
editForm
.
enabled
})
const
index
=
plans
.
value
.
findIndex
((
p
)
=>
p
.
id
===
editingPlanId
.
value
)
if
(
index
!==
-
1
)
{
plans
.
value
[
index
]
=
updated
}
appStore
.
showSuccess
(
t
(
'
admin.scheduledTests.updateSuccess
'
))
editingPlanId
.
value
=
null
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to update plan
'
)
}
finally
{
updating
.
value
=
false
}
}
const
confirmDeletePlan
=
(
plan
:
ScheduledTestPlan
)
=>
{
deletingPlan
.
value
=
plan
showDeleteConfirm
.
value
=
true
}
const
handleDelete
=
async
()
=>
{
if
(
!
deletingPlan
.
value
)
return
try
{
await
adminAPI
.
scheduledTests
.
delete
(
deletingPlan
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.scheduledTests.deleteSuccess
'
))
plans
.
value
=
plans
.
value
.
filter
((
p
)
=>
p
.
id
!==
deletingPlan
.
value
!
.
id
)
if
(
expandedPlanId
.
value
===
deletingPlan
.
value
.
id
)
{
expandedPlanId
.
value
=
null
results
.
value
=
[]
}
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to delete plan
'
)
}
finally
{
showDeleteConfirm
.
value
=
false
deletingPlan
.
value
=
null
}
}
const
toggleExpand
=
async
(
planId
:
number
)
=>
{
if
(
expandedPlanId
.
value
===
planId
)
{
expandedPlanId
.
value
=
null
results
.
value
=
[]
expandedResultIds
.
clear
()
return
}
expandedPlanId
.
value
=
planId
expandedResultIds
.
clear
()
loadingResults
.
value
=
true
try
{
results
.
value
=
await
adminAPI
.
scheduledTests
.
listResults
(
planId
,
20
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
'
Failed to load results
'
)
results
.
value
=
[]
}
finally
{
loadingResults
.
value
=
false
}
}
const
toggleResultDetail
=
(
resultId
:
number
)
=>
{
if
(
expandedResultIds
.
has
(
resultId
))
{
expandedResultIds
.
delete
(
resultId
)
}
else
{
expandedResultIds
.
add
(
resultId
)
}
}
</
script
>
frontend/src/i18n/locales/en.ts
View file @
642432cf
...
...
@@ -2413,6 +2413,34 @@ export default {
'
This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.
'
},
// Scheduled Tests
scheduledTests
:
{
title
:
'
Scheduled Tests
'
,
addPlan
:
'
Add Plan
'
,
editPlan
:
'
Edit Plan
'
,
deletePlan
:
'
Delete Plan
'
,
model
:
'
Model
'
,
cronExpression
:
'
Cron Expression
'
,
enabled
:
'
Enabled
'
,
lastRun
:
'
Last Run
'
,
nextRun
:
'
Next Run
'
,
maxResults
:
'
Max Results
'
,
noPlans
:
'
No scheduled test plans
'
,
confirmDelete
:
'
Are you sure you want to delete this plan?
'
,
createSuccess
:
'
Plan created successfully
'
,
updateSuccess
:
'
Plan updated successfully
'
,
deleteSuccess
:
'
Plan deleted successfully
'
,
results
:
'
Test Results
'
,
noResults
:
'
No test results yet
'
,
responseText
:
'
Response
'
,
errorMessage
:
'
Error
'
,
success
:
'
Success
'
,
failed
:
'
Failed
'
,
running
:
'
Running
'
,
schedule
:
'
Schedule
'
,
cronHelp
:
'
Standard 5-field cron expression (e.g., */30 * * * *)
'
},
// Proxies
proxies
:
{
title
:
'
Proxy Management
'
,
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment