衔接异步任务前后端并记录自动回归进度,竞争复审待完成
This commit is contained in:
parent
13872ea3b1
commit
9e666be522
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
|
func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
|
||||||
@ -62,21 +63,43 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cookies = login.Result().Cookies()
|
cookies = login.Result().Cookies()
|
||||||
initial := request("/api/v1/domains/task-domain/deploy", "")
|
initial := request("/api/v1/domains/task-domain/deploy", "")
|
||||||
if initial.Code != http.StatusBadGateway || calls.Load() != 1 {
|
if initial.Code != http.StatusAccepted {
|
||||||
t.Fatalf("首次故障未经过真实部署接线:%d %s", initial.Code, initial.Body.String())
|
t.Fatalf("首次部署没有异步接收:%d %s", initial.Code, initial.Body.String())
|
||||||
}
|
}
|
||||||
tasks, err := application.Tasks.ListTasks(t.Context(), "task-domain", 10)
|
waitTerminal := func(status string) []repository.Task {
|
||||||
if err != nil || len(tasks) != 1 || tasks[0].Status != "failed" {
|
t.Helper()
|
||||||
t.Fatalf("首次部署未生成唯一失败任务:%+v %v", tasks, err)
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
items, err := application.Tasks.ListTasks(t.Context(), "task-domain", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Status == status {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("等待任务终态超时:%s", status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tasks := waitTerminal("failed")
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatal("首次故障未经过真实部署接线")
|
||||||
}
|
}
|
||||||
task := tasks[0]
|
task := tasks[0]
|
||||||
retryPath := "/api/v1/tasks/" + task.ID + "/retry"
|
retryPath := "/api/v1/tasks/" + task.ID + "/retry"
|
||||||
done := make(chan *httptest.ResponseRecorder, 1)
|
retry := request(retryPath, "")
|
||||||
go func() { done <- request(retryPath, "") }()
|
// 终态持久化后执行器可能仍在释放资源,收尾期间重试必须保持拒绝。
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for retry.Code == http.StatusConflict && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
retry = request(retryPath, "")
|
||||||
|
}
|
||||||
|
if retry.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("真实部署重试未接收:%d %s", retry.Code, retry.Body.String())
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-started:
|
case <-started:
|
||||||
case response := <-done:
|
|
||||||
t.Fatalf("重试未进入原始部署工作流:%d %s", response.Code, response.Body.String())
|
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("重试工作流启动超时")
|
t.Fatal("重试工作流启动超时")
|
||||||
}
|
}
|
||||||
@ -85,17 +108,9 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
|
|||||||
t.Fatalf("并发重试未被阻止:%d %s", concurrent.Code, concurrent.Body.String())
|
t.Fatalf("并发重试未被阻止:%d %s", concurrent.Code, concurrent.Body.String())
|
||||||
}
|
}
|
||||||
release <- struct{}{}
|
release <- struct{}{}
|
||||||
select {
|
tasks = waitTerminal("success")
|
||||||
case response := <-done:
|
if tasks[0].ID != task.ID {
|
||||||
if response.Code != http.StatusOK {
|
t.Fatalf("重试重复创建任务:%+v", tasks)
|
||||||
t.Fatalf("真实应用部署重试失败:%d %s", response.Code, response.Body.String())
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("重试工作流收尾超时")
|
|
||||||
}
|
|
||||||
tasks, err = application.Tasks.ListTasks(t.Context(), "task-domain", 10)
|
|
||||||
if err != nil || len(tasks) != 1 || tasks[0].ID != task.ID || tasks[0].Status != "success" {
|
|
||||||
t.Fatalf("重试重复创建任务或未成功:%+v %v", tasks, err)
|
|
||||||
}
|
}
|
||||||
attempts, err := application.Tasks.ListAttempts(t.Context(), task.ID)
|
attempts, err := application.Tasks.ListAttempts(t.Context(), task.ID)
|
||||||
if err != nil || len(attempts) != 2 {
|
if err != nil || len(attempts) != 2 {
|
||||||
|
|||||||
@ -219,7 +219,7 @@ func (s *Server) registerRoutes() {
|
|||||||
domainAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
domainAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
||||||
domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
|
domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
|
||||||
domainAPI.DELETE("/domains/:id", s.deleteDomain)
|
domainAPI.DELETE("/domains/:id", s.deleteDomain)
|
||||||
domainAPI.POST("/domains/:id/issue", s.longOperation(certificateTaskTimeout), s.issueDomain)
|
domainAPI.POST("/domains/:id/issue", s.issueDomain)
|
||||||
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
|
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
|
||||||
api.GET("/tasks", s.listTasks)
|
api.GET("/tasks", s.listTasks)
|
||||||
api.GET("/tasks/:id", s.getTask)
|
api.GET("/tasks/:id", s.getTask)
|
||||||
@ -237,7 +237,7 @@ func (s *Server) registerRoutes() {
|
|||||||
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
||||||
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
|
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
|
||||||
domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
|
domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
|
||||||
domainAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
|
domainAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent)
|
||||||
domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
|
domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
|
||||||
api.POST("/servers", s.createServer)
|
api.POST("/servers", s.createServer)
|
||||||
api.PUT("/servers/:id", s.updateServer)
|
api.PUT("/servers/:id", s.updateServer)
|
||||||
|
|||||||
@ -170,19 +170,20 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
})
|
})
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if err := executionCtx.Err(); err != nil {
|
if err := executionCtx.Err(); err != nil {
|
||||||
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止")
|
s.finishTaskContext(task, attempt, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.tasks.UpdateTask(executionCtx, task.ID, "running", stage, runningMessage, &now, nil); err != nil {
|
if err := s.tasks.UpdateTask(executionCtx, task.ID, "running", stage, runningMessage, &now, nil); err != nil {
|
||||||
finished := time.Now().UTC()
|
|
||||||
failureMessage := "任务启动失败"
|
failureMessage := "任务启动失败"
|
||||||
if s.finishCancelledTask(task, attempt, executionCtx) {
|
if s.finishCancelledTask(task, attempt, executionCtx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", stage, failureMessage, &now, &finished)
|
attempt.Stage = stage
|
||||||
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished)
|
s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
|
||||||
if s.audit != nil {
|
if s.audit != nil {
|
||||||
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
|
auditCtx, auditCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer auditCancel()
|
||||||
|
_ = s.audit.CreateAuditLog(auditCtx, repository.AuditLog{
|
||||||
TaskID: task.ID, OperatorID: task.OperatorID,
|
TaskID: task.ID, OperatorID: task.OperatorID,
|
||||||
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
|
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
|
||||||
Status: "error", Message: failureMessage,
|
Status: "error", Message: failureMessage,
|
||||||
@ -196,13 +197,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
|
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
|
||||||
Status: "running", Message: runningMessage,
|
Status: "running", Message: runningMessage,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
finished := time.Now().UTC()
|
|
||||||
failureMessage := "任务启动审计记录失败"
|
failureMessage := "任务启动审计记录失败"
|
||||||
if s.finishCancelledTask(task, attempt, executionCtx) {
|
if s.finishCancelledTask(task, attempt, executionCtx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "record", failureMessage, &now, &finished)
|
attempt.Stage = "record"
|
||||||
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished)
|
s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -248,15 +248,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
issueErr = errors.New(message)
|
issueErr = errors.New(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished); err != nil {
|
|
||||||
status, message = "failed", "任务尝试结果保存失败,请检查任务恢复状态"
|
|
||||||
}
|
|
||||||
task.Status = status
|
|
||||||
task.Stage = stage
|
|
||||||
task.Message = message
|
|
||||||
task.StartedAt = &now
|
|
||||||
task.FinishedAt = &finished
|
|
||||||
task.UpdatedAt = finished
|
|
||||||
if s.audit != nil {
|
if s.audit != nil {
|
||||||
auditStatus := status
|
auditStatus := status
|
||||||
if issueErr != nil && status != "cancelled" && status != "partial_success" {
|
if issueErr != nil && status != "cancelled" && status != "partial_success" {
|
||||||
@ -269,15 +260,28 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
status, message = "failed", "任务完成审计保存失败,请检查任务恢复状态"
|
status, message = "failed", "任务完成审计保存失败,请检查任务恢复状态"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := s.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished); err != nil {
|
||||||
|
status, message = "failed", "任务尝试结果保存失败,请检查任务恢复状态"
|
||||||
|
}
|
||||||
if err := s.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &now, &finished); err != nil && s.logger != nil {
|
if err := s.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &now, &finished); err != nil && s.logger != nil {
|
||||||
s.logger.Error("保存任务终态失败", "task_id", task.ID, "error", err)
|
s.logger.Error("保存任务终态失败", "task_id", task.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) finishCancelledTask(task repository.Task, attempt repository.TaskAttempt, ctx context.Context) bool {
|
func (s *Server) finishCancelledTask(task repository.Task, attempt repository.TaskAttempt, ctx context.Context) bool {
|
||||||
persisted, err := s.tasks.GetTask(context.Background(), task.ID)
|
if ctx.Err() != nil {
|
||||||
if (err == nil && (persisted.Status == "cancelled" || persisted.Status == "cancelling")) || errors.Is(ctx.Err(), context.Canceled) {
|
s.finishTaskContext(task, attempt, ctx.Err())
|
||||||
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务已取消,未继续执行")
|
return true
|
||||||
|
}
|
||||||
|
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
persisted, err := s.tasks.GetTask(checkCtx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.finishTaskBeforeExecution(task, attempt, "failed", "无法确认任务状态,未执行外部操作")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if persisted.Status == "cancelled" || persisted.Status == "cancelling" {
|
||||||
|
s.finishTaskContext(task, attempt, context.Canceled)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -28,9 +28,10 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) {
|
|||||||
defer closeDB()
|
defer closeDB()
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
if response.Code != http.StatusMultiStatus {
|
if response.Code != http.StatusAccepted {
|
||||||
t.Fatalf("部分部署失败响应错误:%d %s", response.Code, response.Body.String())
|
t.Fatalf("任务提交响应错误:%d %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
items, err := tasks.ListTasks(t.Context(), "", 10)
|
items, err := tasks.ListTasks(t.Context(), "", 10)
|
||||||
if err != nil || len(items) != 1 || items[0].Status != "partial_success" {
|
if err != nil || len(items) != 1 || items[0].Status != "partial_success" {
|
||||||
t.Fatalf("部分成功任务记录错误:%+v %v", items, err)
|
t.Fatalf("部分成功任务记录错误:%+v %v", items, err)
|
||||||
@ -86,9 +87,10 @@ func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) {
|
|||||||
defer closeDB()
|
defer closeDB()
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "secret-path") || !strings.Contains(response.Body.String(), "lock_unavailable") {
|
if response.Code != http.StatusAccepted || strings.Contains(response.Body.String(), "secret-path") {
|
||||||
t.Fatalf("部署接口缺少安全的锁诊断:%d %s", response.Code, response.Body.String())
|
t.Fatalf("任务提交失败或泄露诊断原文:%d %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
items, err := tasks.ListTasks(t.Context(), "", 10)
|
items, err := tasks.ListTasks(t.Context(), "", 10)
|
||||||
if err != nil || len(items) != 1 {
|
if err != nil || len(items) != 1 {
|
||||||
t.Fatalf("任务未保存:%+v %v", items, err)
|
t.Fatalf("任务未保存:%+v %v", items, err)
|
||||||
@ -116,21 +118,19 @@ func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T
|
|||||||
defer closeDB()
|
defer closeDB()
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
if response.Code != http.StatusBadGateway {
|
if response.Code != http.StatusAccepted {
|
||||||
t.Fatalf("HTTPS 校验失败应返回失败响应:%d %s", response.Code, response.Body.String())
|
t.Fatalf("任务提交失败:%d %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
var payload struct {
|
waitTaskWorkersForTest(t, server)
|
||||||
Deployment deployment.Report `json:"deployment"`
|
location := response.Header().Get("Location")
|
||||||
TaskStatus string `json:"task_status"`
|
response = httptest.NewRecorder()
|
||||||
}
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, location, nil))
|
||||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
var task repository.Task
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if payload.TaskStatus != "failed" || payload.Deployment.Status != "failed" || payload.Deployment.VerificationStatus != "failed" {
|
if response.Code != http.StatusOK || task.Status != "failed" || strings.Contains(response.Body.String(), "/secret-path") {
|
||||||
t.Fatalf("接口未保留整体失败与站点校验失败:%+v", payload)
|
t.Fatalf("状态查询未保留整体失败或泄露底层错误:%d %s", response.Code, response.Body.String())
|
||||||
}
|
|
||||||
if payload.Deployment.Success != 1 || payload.Deployment.Results[0].Status != "success" || strings.Contains(response.Body.String(), "/secret-path") {
|
|
||||||
t.Fatalf("接口丢失真实推送结果或泄露底层错误:%s", response.Body.String())
|
|
||||||
}
|
}
|
||||||
items, err := tasks.ListTasks(t.Context(), "", 10)
|
items, err := tasks.ListTasks(t.Context(), "", 10)
|
||||||
if err != nil || len(items) != 1 || items[0].Status != "failed" {
|
if err != nil || len(items) != 1 || items[0].Status != "failed" {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
|||||||
case s.taskSlots <- struct{}{}:
|
case s.taskSlots <- struct{}{}:
|
||||||
defer func() { <-s.taskSlots }()
|
defer func() { <-s.taskSlots }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止")
|
s.finishTaskContext(task, attempt, ctx.Err())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
||||||
@ -56,6 +57,14 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
|||||||
c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true})
|
c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) finishTaskContext(task repository.Task, attempt repository.TaskAttempt, err error) {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
s.finishTaskBeforeExecution(task, attempt, "failed", "任务执行超时,未继续执行")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务已取消,未继续执行")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt repository.TaskAttempt, status, message string) {
|
func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt repository.TaskAttempt, status, message string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -15,6 +17,128 @@ import (
|
|||||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAsyncQueueOverloadClosesPersistedTask(t *testing.T) {
|
||||||
|
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
t.Error("队列已满时不能执行外部操作")
|
||||||
|
return certificate.IssueResult{}, deployment.Report{}, nil
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
server.asyncTasks = make(map[string]bool)
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
server.asyncTasks[strconv.Itoa(i)] = true
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
|
if response.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("队列过载未拒绝:%d %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
items, err := tasks.ListTasks(t.Context(), "", 10)
|
||||||
|
if err != nil || len(items) != 1 || items[0].Status != "failed" {
|
||||||
|
t.Fatalf("过载后遗留活动任务:%+v %v", items, err)
|
||||||
|
}
|
||||||
|
attempts, err := tasks.ListAttempts(t.Context(), items[0].ID)
|
||||||
|
if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" {
|
||||||
|
t.Fatalf("过载后尝试未关闭:%+v %v", attempts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncQueuedCancellationNeverStartsOperation(t *testing.T) {
|
||||||
|
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
t.Error("排队期间取消的任务不能执行外部操作")
|
||||||
|
return certificate.IssueResult{}, deployment.Report{}, nil
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
server.asyncTasks = make(map[string]bool)
|
||||||
|
server.taskSlots = make(chan struct{}, 4)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
server.taskSlots <- struct{}{}
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
|
task := submittedTask(t, response)
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
|
||||||
|
if response.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("排队取消未接收:%d %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
if err != nil || stored.Status != "cancelled" {
|
||||||
|
t.Fatalf("排队取消终态错误:%+v %v", stored, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncPanicClosesTaskWithoutExposingPanic(t *testing.T) {
|
||||||
|
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
panic("private-panic-secret")
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
|
task := submittedTask(t, response)
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
if err != nil || stored.Status != "failed" || strings.Contains(stored.Message, "private-panic-secret") {
|
||||||
|
t.Fatalf("异常任务未安全关闭:%+v %v", stored, err)
|
||||||
|
}
|
||||||
|
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
||||||
|
if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || strings.Contains(attempts[0].Message, "private-panic-secret") {
|
||||||
|
t.Fatalf("异常尝试未安全关闭:%+v %v", attempts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncDeadlineBeforeExecutionIsFailureNotUserCancellation(t *testing.T) {
|
||||||
|
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||||
|
defer cleanup()
|
||||||
|
task := repository.Task{ID: "deadline-task", DomainID: "domain-1", TaskType: "certificate_issue", Status: "queued", Stage: "queued"}
|
||||||
|
attempt := repository.TaskAttempt{ID: "deadline-attempt", TaskID: task.ID, AttemptNumber: 1, Status: "queued", Stage: "queued"}
|
||||||
|
if err := tasks.CreateTask(t.Context(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tasks.CreateAttempt(t.Context(), attempt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second))
|
||||||
|
defer cancel()
|
||||||
|
server.runCertificateTask(ctx, task, attempt, "certificate_issue", func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
t.Error("超时任务不能执行外部操作")
|
||||||
|
return certificate.IssueResult{}, deployment.Report{}, nil
|
||||||
|
}, "issue", "执行中", "执行完成")
|
||||||
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
if err != nil || stored.Status != "failed" || !strings.Contains(stored.Message, "超时") {
|
||||||
|
t.Fatalf("超时被误记为用户取消:%+v %v", stored, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingTaskFinishAuditRepository struct{ repository.AuditRepository }
|
||||||
|
|
||||||
|
func (r failingTaskFinishAuditRepository) CreateAuditLog(ctx context.Context, item repository.AuditLog) error {
|
||||||
|
if item.Status == "success" {
|
||||||
|
return errors.New("模拟完成审计写入失败")
|
||||||
|
}
|
||||||
|
return r.AuditRepository.CreateAuditLog(ctx, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncFinishAuditFailureDoesNotLeaveSuccessfulAttempt(t *testing.T) {
|
||||||
|
server, tasks, audit, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
server.audit = failingTaskFinishAuditRepository{audit}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
|
task := submittedTask(t, response)
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
if err != nil || stored.Status != "failed" {
|
||||||
|
t.Fatalf("完成审计失败却标记成功:%+v %v", stored, err)
|
||||||
|
}
|
||||||
|
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
||||||
|
if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].Message != stored.Message {
|
||||||
|
t.Fatalf("任务与尝试终态不一致:%+v %v", attempts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func submittedTask(t *testing.T, response *httptest.ResponseRecorder) repository.Task {
|
func submittedTask(t *testing.T, response *httptest.ResponseRecorder) repository.Task {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if response.Code != http.StatusAccepted {
|
if response.Code != http.StatusAccepted {
|
||||||
|
|||||||
@ -17,7 +17,8 @@ ExecStart=/scert/scert --config /scert/conf/scert.json serve
|
|||||||
Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external
|
Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external
|
||||||
Environment=SCERT_SERVER_HOST=127.0.0.1
|
Environment=SCERT_SERVER_HOST=127.0.0.1
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=30
|
# 为任务取消、部署回滚及持久化收尾预留时间,超过预算仍由启动恢复处理。
|
||||||
|
TimeoutStopSec=180
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
UMask=0077
|
UMask=0077
|
||||||
RuntimeDirectory=scert
|
RuntimeDirectory=scert
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
# Linux 生产流程测试记录
|
# Linux 生产流程测试记录
|
||||||
|
|
||||||
|
## 2026年10月2日16:47 异步任务开发回归记录(未部署)
|
||||||
|
|
||||||
|
### 已完成的开发回归
|
||||||
|
|
||||||
|
- [x] 本机前端:npm.cmd test 为 252 项通过、零失败、零跳过;npm.cmd run build 通过。日志为 .runtime/async-frontend-20261002-final.log 与 .runtime/async-frontend-build-20261002-final.log。
|
||||||
|
- [x] 第二台隔离源码、非特权用户执行 go test ./... -count=1 和 go vet ./... 均返回零;日志位于 /var/tmp/scert-async-20261002-1556/backend-full.log 与 backend-vet.log。首次旧同步接口断言失败已修复,原始日志保留。
|
||||||
|
- [~] 第二台竞争检测因未启用 CGO 且无本机编译器未执行;第一台首次离线代理缺少依赖也未执行用例。已复用第二台公共模块缓存、三端校验归档摘要一致,在第一台启用 CGO 后重新执行异步专项竞争检测二十次,等待结果。
|
||||||
|
|
||||||
|
### 证据边界和剩余任务
|
||||||
|
|
||||||
|
- [~] 复核发现分发单元与动态安装模板的停止预算尚未同步,继续修复并回归,不以静态文件修改代替实际安装行为。
|
||||||
|
- [!] 本批未替换线上候选,未执行浏览器首次初始化,未清理线上数据库,未新增辅助验收脚本,也未降低 TLS 或 SSH 主机身份校验。
|
||||||
|
- [ ] 正式候选统一部署后仍需验证长任务超过反代等待窗口、断线后查询、取消回滚、进程重启恢复及三数据库首次完整 Web 流程。
|
||||||
|
- [!] 下方 504 与 502 为真实历史失败,异步开发回归通过并不自动关闭实机问题;旧事务、系统通知、备份恢复及总体生产门禁继续保留。
|
||||||
|
|
||||||
## 2026年10月2日15:31 正式候选实机负向恢复与重试
|
## 2026年10月2日15:31 正式候选实机负向恢复与重试
|
||||||
|
|
||||||
### 范围和准备
|
### 范围和准备
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
# Scert 整改任务执行清单
|
# Scert 整改任务执行清单
|
||||||
|
|
||||||
|
## 2026年10月2日16:47 异步开发与自动回归进度
|
||||||
|
|
||||||
|
本节更新下方 15:43 的早期状态。范围仍为 Linux;先完成开发和自动回归,再统一正式部署验收。开发回归使用隔离源码与临时数据库,不清理线上业务数据。
|
||||||
|
|
||||||
|
- [x] 申请、首次部署、重试持久化任务与尝试后返回 202;后台上下文不依赖浏览器连接,保留项目授权、幂等键和任务历史。
|
||||||
|
- [x] 已实现任务取消收尾、关闭时拒绝新工作、有限执行槽、超时和异常终态;补充排队取消、队列满、异常脱敏和审计保存失败回归,等待竞争检测及复审。
|
||||||
|
- [x] 前端已接入任务查询、最近尝试的逐服务器结果、刷新后查看及撤权清缓存;查询失败不自动重新提交,提交成功不等于部署成功。
|
||||||
|
- [x] 本机前端 252 项测试全部通过,生产构建通过;第二台隔离 Linux 全后端测试与 go vet 通过。上述结果均非正式部署验收。
|
||||||
|
- [~] 第一台使用现有编译器运行异步专项竞争检测,重复二十次;第二台缺少编译器、第一台首次缺少离线依赖的记录保留,不将环境失败计作产品用例失败或通过。
|
||||||
|
- [~] 复核发现静态 systemd 单元已增加停止预算,但动态安装模板仍为三十秒;下一步同步模板并增加一致性测试。
|
||||||
|
- [ ] 补齐异步查询结果契约、持久化失败及取消提交竞态的复核;其他长操作仍需逐项整改。
|
||||||
|
- [!] 本批代码未部署,重启 502、两条缺证据历史事务、系统通知与密码重置、三数据库首次 Web 流程、备份恢复及最终生产门禁仍未关闭。
|
||||||
|
|
||||||
## 2026年10月2日15:43 长任务异步整改入口
|
## 2026年10月2日15:43 长任务异步整改入口
|
||||||
|
|
||||||
需求确认:正式申请、部署与重试不能等待整个工作流结束才响应;沿用持久化任务与权限边界,不以增加代理超时替代异步执行。仅处理 Linux,开发回归不清理线上业务数据;首次初始化验收另行清库执行。
|
需求确认:正式申请、部署与重试不能等待整个工作流结束才响应;沿用持久化任务与权限边界,不以增加代理超时替代异步执行。仅处理 Linux,开发回归不清理线上业务数据;首次初始化验收另行清库执行。
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
# Scert 整改、合并与优化清单
|
# Scert 整改、合并与优化清单
|
||||||
|
|
||||||
|
## 2026年10月2日16:47 最新开发进度入口
|
||||||
|
|
||||||
|
- [x] P0-10/P1-04 的申请、部署、重试已完成异步提交和前端任务查询接线,浏览器断线不终止已接收任务;最近尝试、取消收尾和查询失败提示已补充。完成待复审,不表示整个条目关闭。
|
||||||
|
- [x] 本机前端 252 项全部通过、生产构建通过;第二台隔离原生 Linux 全后端测试与 go vet 通过。本批尚未部署,不能沿用旧候选实机结果作为新代码验收。
|
||||||
|
- [~] 异步专项竞争检测正在第一台隔离环境执行;同时补核动态安装模板停止预算与终态持久化边界。
|
||||||
|
- [!] 其他长操作、单实例重启 502、旧恢复事务证据、通知重置、三数据库首次流程和备份恢复仍有未完成项。P0-10、P0-11 和整体生产门禁继续保持未通过。
|
||||||
|
|
||||||
|
详细任务点见《Scert整改任务执行清单》,开发回归与实机验收边界见《Linux生产流程测试记录》。下方按时间保留历史证据,不以旧入口覆盖本节进度。
|
||||||
|
|
||||||
## 2026年10月2日15:31 最新进度入口
|
## 2026年10月2日15:31 最新进度入口
|
||||||
|
|
||||||
- [x] 新候选真实部署负向检查:旧文件摘要变化时停止回滚并保留完整现场;启动恢复遇到锁归属变化同样拒绝接管。仅还原受控注入后,产品自动完成双机回滚。
|
- [x] 新候选真实部署负向检查:旧文件摘要变化时停止回滚并保留完整现场;启动恢复遇到锁归属变化同样拒绝接管。仅还原受控注入后,产品自动完成双机回滚。
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||||||
import { settingsPayload } from "./SettingsPayload.js";
|
import { settingsPayload } from "./SettingsPayload.js";
|
||||||
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
|
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
|
||||||
import { useDomainForm } from "./DomainForm.js";
|
import { useDomainForm } from "./DomainForm.js";
|
||||||
|
import { useTaskMonitor } from "./TaskMonitor.js";
|
||||||
|
|
||||||
const defaultDNSVerificationTimeoutSeconds = 60;
|
const defaultDNSVerificationTimeoutSeconds = 60;
|
||||||
const aliyunDNSVerificationTimeoutSeconds = 6;
|
const aliyunDNSVerificationTimeoutSeconds = 6;
|
||||||
@ -75,6 +76,11 @@ export function useDashboard() {
|
|||||||
const taskDetailsError = ref("");
|
const taskDetailsError = ref("");
|
||||||
const deploymentResult = ref(null);
|
const deploymentResult = ref(null);
|
||||||
const domainActionRunning = ref(false);
|
const domainActionRunning = ref(false);
|
||||||
|
const taskMonitor = useTaskMonitor({
|
||||||
|
fetchAdmin, tasks, deploymentResult, showTaskDetails, taskDetails,
|
||||||
|
taskAttempts, taskDetailsLoading, taskDetailsError,
|
||||||
|
refreshResources: refreshTaskResources,
|
||||||
|
});
|
||||||
const hasOpenModal = computed(
|
const hasOpenModal = computed(
|
||||||
() =>
|
() =>
|
||||||
showForm.value ||
|
showForm.value ||
|
||||||
@ -549,6 +555,28 @@ export function useDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTaskResources(signal) {
|
||||||
|
// 任务结束后只刷新运行数据,不覆盖用户正在编辑的系统设置或表单。
|
||||||
|
const responses = await Promise.all([
|
||||||
|
fetchAdmin("/api/v1/status", { signal }),
|
||||||
|
fetchAdmin("/api/v1/domains", { signal }),
|
||||||
|
fetchAdmin("/api/v1/certificates", { signal }),
|
||||||
|
fetchAdmin("/api/v1/audit-logs?limit=20", { signal }),
|
||||||
|
]);
|
||||||
|
if (responses.some((response) => !response.ok)) throw new Error("刷新任务关联数据失败");
|
||||||
|
const [statusResult, domainResult, certificateResult, auditResult] = await Promise.all(
|
||||||
|
responses.map((response) => response.json()),
|
||||||
|
);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
if (![domainResult, certificateResult, auditResult].every((result) => Array.isArray(result.items))) {
|
||||||
|
throw new Error("任务关联数据响应不完整");
|
||||||
|
}
|
||||||
|
status.value = statusResult;
|
||||||
|
domains.value = domainResult.items;
|
||||||
|
certificates.value = certificateResult.items;
|
||||||
|
auditLogs.value = auditResult.items;
|
||||||
|
}
|
||||||
|
|
||||||
async function runDomainAction(item, action, successMessage) {
|
async function runDomainAction(item, action, successMessage) {
|
||||||
if (domainActionRunning.value) return;
|
if (domainActionRunning.value) return;
|
||||||
domainActionRunning.value = true;
|
domainActionRunning.value = true;
|
||||||
@ -559,12 +587,26 @@ export function useDashboard() {
|
|||||||
notice.value = "";
|
notice.value = "";
|
||||||
try {
|
try {
|
||||||
const options = { method: "POST" };
|
const options = { method: "POST" };
|
||||||
if (action === "issue") {
|
if (action === "issue" || action === "deploy") {
|
||||||
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
||||||
options.headers = { "Idempotency-Key": `issue-${item.id}-${suffix}` };
|
options.headers = { "Idempotency-Key": `${action}-${item.id}-${suffix}` };
|
||||||
}
|
}
|
||||||
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
|
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 202 && !result.task?.id) {
|
||||||
|
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
|
||||||
|
}
|
||||||
|
if (response.ok && result.task?.id && (response.status === 202 || result.accepted || result.replayed)) {
|
||||||
|
notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`;
|
||||||
|
if (action === "deploy") {
|
||||||
|
deploymentResult.value = {
|
||||||
|
domain: item.name, running: false, pending: true,
|
||||||
|
taskID: result.task.id, taskStatus: result.task.status,
|
||||||
|
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "deploy") {
|
if (action === "deploy") {
|
||||||
deploymentResult.value = {
|
deploymentResult.value = {
|
||||||
domain: item.name,
|
domain: item.name,
|
||||||
@ -601,39 +643,6 @@ export function useDashboard() {
|
|||||||
return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`);
|
return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openTaskDetails(item) {
|
|
||||||
showTaskDetails.value = true;
|
|
||||||
taskDetails.value = null;
|
|
||||||
taskAttempts.value = [];
|
|
||||||
taskDetailsError.value = "";
|
|
||||||
taskDetailsLoading.value = true;
|
|
||||||
try {
|
|
||||||
const [taskResponse, attemptsResponse] = await Promise.all([
|
|
||||||
fetchAdmin(`/api/v1/tasks/${item.id}`),
|
|
||||||
fetchAdmin(`/api/v1/tasks/${item.id}/attempts`),
|
|
||||||
]);
|
|
||||||
if (!taskResponse.ok) {
|
|
||||||
throw new Error(await responseError(taskResponse, "读取任务详情失败"));
|
|
||||||
}
|
|
||||||
if (!attemptsResponse.ok) {
|
|
||||||
throw new Error(await responseError(attemptsResponse, "读取任务尝试记录失败"));
|
|
||||||
}
|
|
||||||
taskDetails.value = await taskResponse.json();
|
|
||||||
taskAttempts.value = (await attemptsResponse.json()).items || [];
|
|
||||||
} catch (err) {
|
|
||||||
taskDetailsError.value = err.message || "读取任务详情失败";
|
|
||||||
} finally {
|
|
||||||
taskDetailsLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTaskDetails() {
|
|
||||||
showTaskDetails.value = false;
|
|
||||||
taskDetails.value = null;
|
|
||||||
taskAttempts.value = [];
|
|
||||||
taskDetailsError.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelTask(item) {
|
async function cancelTask(item) {
|
||||||
if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return;
|
if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@ -642,8 +651,11 @@ export function useDashboard() {
|
|||||||
const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" });
|
const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" });
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(result.error || "取消任务失败");
|
if (!response.ok) throw new Error(result.error || "取消任务失败");
|
||||||
notice.value = "证书任务已取消";
|
notice.value = response.status === 202 || result.task?.status === "cancelling"
|
||||||
|
? "正在取消,等待任务停止和部署收尾;此时不能重试"
|
||||||
|
: "证书任务已取消";
|
||||||
await loadData();
|
await loadData();
|
||||||
|
await taskMonitor.refreshTaskStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "取消任务失败";
|
error.value = err.message || "取消任务失败";
|
||||||
}
|
}
|
||||||
@ -656,8 +668,9 @@ export function useDashboard() {
|
|||||||
const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" });
|
const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" });
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(result.error || "重试任务失败");
|
if (!response.ok) throw new Error(result.error || "重试任务失败");
|
||||||
notice.value = "证书任务已重新执行";
|
notice.value = "重试已提交,请在任务详情查看本次执行结果";
|
||||||
await loadData();
|
await loadData();
|
||||||
|
await taskMonitor.refreshTaskStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "重试任务失败";
|
error.value = err.message || "重试任务失败";
|
||||||
}
|
}
|
||||||
@ -1022,6 +1035,7 @@ export function useDashboard() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...domainForm,
|
...domainForm,
|
||||||
|
...taskMonitor,
|
||||||
status,
|
status,
|
||||||
currentUser,
|
currentUser,
|
||||||
canManageSystemSettings,
|
canManageSystemSettings,
|
||||||
@ -1112,8 +1126,6 @@ export function useDashboard() {
|
|||||||
deployDomain,
|
deployDomain,
|
||||||
cancelTask,
|
cancelTask,
|
||||||
retryTask,
|
retryTask,
|
||||||
openTaskDetails,
|
|
||||||
closeTaskDetails,
|
|
||||||
showTaskDetails,
|
showTaskDetails,
|
||||||
taskDetails,
|
taskDetails,
|
||||||
taskAttempts,
|
taskAttempts,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import "./DeploymentResult.test.js";
|
import "./DeploymentResult.test.js";
|
||||||
|
import "./TaskMonitor.test.js";
|
||||||
import "./DashboardLogout.test.js";
|
import "./DashboardLogout.test.js";
|
||||||
import "./DomainModal.test.js";
|
import "./DomainModal.test.js";
|
||||||
import "./DomainForm.test.js";
|
import "./DomainForm.test.js";
|
||||||
@ -139,6 +140,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
|||||||
app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) });
|
app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) });
|
||||||
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
|
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
|
||||||
app.component("DeploymentResult", { render: () => null });
|
app.component("DeploymentResult", { render: () => null });
|
||||||
|
app.component("TaskDeploymentResults", { render: () => null });
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
app.config.warnHandler = (message) => warnings.push(message);
|
app.config.warnHandler = (message) => warnings.push(message);
|
||||||
const html = await renderToString(app);
|
const html = await renderToString(app);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import DashboardNavigation from "./DashboardNavigation.vue";
|
|||||||
import { useDashboardNavigation } from "./DashboardNavigation.js";
|
import { useDashboardNavigation } from "./DashboardNavigation.js";
|
||||||
import { useDashboard } from "./Dashboard.js";
|
import { useDashboard } from "./Dashboard.js";
|
||||||
import DeploymentResult from "./DeploymentResult.vue";
|
import DeploymentResult from "./DeploymentResult.vue";
|
||||||
|
import TaskDeploymentResults from "./TaskDeploymentResults.vue";
|
||||||
|
|
||||||
const dashboard = useDashboard();
|
const dashboard = useDashboard();
|
||||||
const {
|
const {
|
||||||
@ -37,6 +38,8 @@ const {
|
|||||||
saving,
|
saving,
|
||||||
error,
|
error,
|
||||||
notice,
|
notice,
|
||||||
|
taskQueryError,
|
||||||
|
taskDeploymentReport,
|
||||||
formError,
|
formError,
|
||||||
showForm,
|
showForm,
|
||||||
editingId,
|
editingId,
|
||||||
@ -152,6 +155,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
|||||||
<div class="dashboard-messages" aria-live="polite">
|
<div class="dashboard-messages" aria-live="polite">
|
||||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||||
<p v-if="notice" class="notice-banner">{{ notice }}</p>
|
<p v-if="notice" class="notice-banner">{{ notice }}</p>
|
||||||
|
<p v-if="taskQueryError" class="error-banner" role="alert">{{ taskQueryError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-workspace">
|
<div class="dashboard-workspace">
|
||||||
@ -504,8 +508,8 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
|||||||
<button class="close-button" type="button" aria-label="关闭" @click="closeTaskDetails">×</button>
|
<button class="close-button" type="button" aria-label="关闭" @click="closeTaskDetails">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskDetailsLoading" class="empty-state">正在读取任务详情……</div>
|
<div v-if="taskDetailsLoading" class="empty-state">正在读取任务详情……</div>
|
||||||
<p v-else-if="taskDetailsError" class="form-error">{{ taskDetailsError }}</p>
|
<p v-if="taskDetailsError" class="form-error" role="alert">{{ taskDetailsError }}</p>
|
||||||
<template v-else-if="taskDetails">
|
<template v-if="taskDetails">
|
||||||
<div class="task-detail-summary">
|
<div class="task-detail-summary">
|
||||||
<div><span>当前状态</span><strong class="tag" :class="statusClass(taskDetails.status)">{{ displayLabel(taskStatusLabels, taskDetails.status) }}</strong></div>
|
<div><span>当前状态</span><strong class="tag" :class="statusClass(taskDetails.status)">{{ displayLabel(taskStatusLabels, taskDetails.status) }}</strong></div>
|
||||||
<div><span>执行阶段</span><strong>{{ taskStageLabel(taskDetails.stage) }}</strong></div>
|
<div><span>执行阶段</span><strong>{{ taskStageLabel(taskDetails.stage) }}</strong></div>
|
||||||
@ -516,6 +520,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
|||||||
<span>任务信息</span>
|
<span>任务信息</span>
|
||||||
<p>{{ taskDetails.message || "无附加信息" }}</p>
|
<p>{{ taskDetails.message || "无附加信息" }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskDeploymentResults v-if="taskDeploymentReport" :report="taskDeploymentReport" />
|
||||||
<h3 class="modal-section-title">执行尝试</h3>
|
<h3 class="modal-section-title">执行尝试</h3>
|
||||||
<div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div>
|
<div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div>
|
||||||
<div v-else class="attempt-list">
|
<div v-else class="attempt-list">
|
||||||
|
|||||||
@ -2,6 +2,14 @@ export function serverAuthLabel(server) {
|
|||||||
return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置";
|
return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function taskExecutionLabel(status) {
|
||||||
|
return {
|
||||||
|
queued: "排队中", running: "执行中", retry_wait: "等待重试",
|
||||||
|
cancelling: "正在取消,等待收尾", cancelled: "已取消",
|
||||||
|
success: "已完成", failed: "失败", partial_success: "部分成功",
|
||||||
|
}[status] || "等待确认状态";
|
||||||
|
}
|
||||||
|
|
||||||
export function deploymentSummary(report) {
|
export function deploymentSummary(report) {
|
||||||
if (!report || !Array.isArray(report.results)) return "未收到有效部署结果";
|
if (!report || !Array.isArray(report.results)) return "未收到有效部署结果";
|
||||||
if (report.status !== "success" && report.recovery_status === "success") {
|
if (report.status !== "success" && report.recovery_status === "success") {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { deploymentStatusLabel, deploymentSummary } from "./DeploymentFeedback.js";
|
import { deploymentStatusLabel, deploymentSummary, taskExecutionLabel } from "./DeploymentFeedback.js";
|
||||||
|
|
||||||
defineProps({ result: { type: Object, required: true } });
|
defineProps({ result: { type: Object, required: true } });
|
||||||
defineEmits(["close"]);
|
defineEmits(["close"]);
|
||||||
@ -27,8 +27,14 @@ defineEmits(["close"]);
|
|||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="result.running" role="status">正在连接服务器、推送证书并校验重载,请稍候……</p>
|
<p v-if="result.running" role="status">正在提交操作,请稍候……</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<template v-if="result.taskID">
|
||||||
|
<p class="muted">任务编号:{{ result.taskID }}</p>
|
||||||
|
<p role="status">{{ taskExecutionLabel(result.taskStatus) }}:{{ result.taskMessage }}</p>
|
||||||
|
<p v-if="result.pending" class="muted">任务已在后台接收,可关闭此窗口,在运行概览继续查看;请勿重复提交。</p>
|
||||||
|
<p v-if="result.queryError" class="form-error" role="alert">{{ result.queryError }}</p>
|
||||||
|
</template>
|
||||||
<p v-if="result.certificateImported" role="status">证书已导入并保存。</p>
|
<p v-if="result.certificateImported" role="status">证书已导入并保存。</p>
|
||||||
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
|
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
|
||||||
<p v-if="result.report" class="deployment-summary" role="status">
|
<p v-if="result.report" class="deployment-summary" role="status">
|
||||||
@ -49,7 +55,7 @@ defineEmits(["close"]);
|
|||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">各服务器结果记录文件推送、配置校验与重载;站点 HTTPS 校验单独汇总,不代表逐台服务器均已验证。</p>
|
<p v-if="result.report" class="muted">各服务器结果仅记录本次尝试的推送结果,整体状态以任务终态为准;不能据此推断每台站点的 HTTPS 校验均通过。</p>
|
||||||
</template>
|
</template>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">
|
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">
|
||||||
|
|||||||
24
frontend/src/views/Dashboard/TaskDeploymentResults.vue
Normal file
24
frontend/src/views/Dashboard/TaskDeploymentResults.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { deploymentSummary, deploymentStatusLabel } from "./DeploymentFeedback.js";
|
||||||
|
|
||||||
|
defineProps({ report: { type: Object, required: true } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section aria-label="最近一次尝试的部署结果">
|
||||||
|
<h3 class="modal-section-title">本次部署结果</h3>
|
||||||
|
<p role="status">{{ deploymentSummary(report) }}</p>
|
||||||
|
<div class="attempt-list">
|
||||||
|
<article v-for="(item, index) in report.results" :key="item.server_id || index" class="attempt-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.server || "未命名服务器" }}</strong>
|
||||||
|
<span class="muted">{{ item.message || "无附加信息" }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="tag">{{ deploymentStatusLabel(item.status) }}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p class="muted">逐服务器结果不代表整体部署或站点 HTTPS 校验成功,请结合任务状态判断。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./Dashboard.css"></style>
|
||||||
190
frontend/src/views/Dashboard/TaskMonitor.js
Normal file
190
frontend/src/views/Dashboard/TaskMonitor.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const terminalStatuses = new Set(["success", "failed", "cancelled", "partial_success"]);
|
||||||
|
|
||||||
|
export function taskIsPending(task) {
|
||||||
|
return !terminalStatuses.has(task?.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latestDeploymentReport(task, attempts, results) {
|
||||||
|
const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0));
|
||||||
|
const items = results.filter((item) => item.attempt_number === latest);
|
||||||
|
if (taskIsPending(task) || latest === 0 || items.length === 0) return null;
|
||||||
|
return {
|
||||||
|
status: task.status,
|
||||||
|
total: items.length,
|
||||||
|
success: items.filter((item) => item.status === "success").length,
|
||||||
|
failed: items.filter((item) => item.status !== "success").length,
|
||||||
|
results: items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询失败只表示暂时无法确认状态,不能据此重发任务或宣布执行失败。
|
||||||
|
export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDetails,
|
||||||
|
taskDetails, taskAttempts, taskDetailsLoading, taskDetailsError, refreshResources }) {
|
||||||
|
const taskQueryError = ref("");
|
||||||
|
const taskDeploymentReport = ref(null);
|
||||||
|
let selectedID = "";
|
||||||
|
let stopped = false;
|
||||||
|
let timer;
|
||||||
|
let controller;
|
||||||
|
let inFlight;
|
||||||
|
let resourcesDirty = false;
|
||||||
|
|
||||||
|
async function readJSON(path, signal) {
|
||||||
|
const response = await fetchAdmin(path, { signal });
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result) {
|
||||||
|
const error = new Error("任务状态查询失败,将自动重试;请勿重复提交任务");
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTask(id, signal) {
|
||||||
|
const path = `/api/v1/tasks/${encodeURIComponent(id)}`;
|
||||||
|
const task = await readJSON(path, signal);
|
||||||
|
if (task.id !== id || !task.status) throw new Error("任务状态响应不完整,将自动重试");
|
||||||
|
const [attempts, results] = await Promise.all([
|
||||||
|
readJSON(`${path}/attempts`, signal),
|
||||||
|
readJSON(`${path}/deployment-results`, signal),
|
||||||
|
]);
|
||||||
|
if (!Array.isArray(attempts.items) || !Array.isArray(results.items)) {
|
||||||
|
throw new Error("任务执行记录响应不完整,将自动重试");
|
||||||
|
}
|
||||||
|
const latestAttempt = attempts.items.reduce((latest, item) =>
|
||||||
|
!latest || item.attempt_number > latest.attempt_number ? item : latest, null);
|
||||||
|
if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) {
|
||||||
|
throw new Error("任务记录正在更新,将重新查询一致的执行结果");
|
||||||
|
}
|
||||||
|
return { task, attempts: attempts.items, report: latestDeploymentReport(task, attempts.items, results.items) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
const activeController = new AbortController();
|
||||||
|
controller = activeController;
|
||||||
|
const signal = activeController.signal;
|
||||||
|
const timeout = setTimeout(() => activeController.abort(), 10000);
|
||||||
|
const detailID = selectedID;
|
||||||
|
const deploymentID = deploymentResult.value?.pending ? deploymentResult.value.taskID : "";
|
||||||
|
try {
|
||||||
|
const list = await readJSON("/api/v1/tasks?limit=20", signal);
|
||||||
|
if (!Array.isArray(list.items)) throw new Error("任务列表响应不完整,将自动重试");
|
||||||
|
if (stopped) return;
|
||||||
|
const previous = new Map(tasks.value.map((item) => [item.id, item]));
|
||||||
|
if (list.items.some((item) => previous.has(item.id) &&
|
||||||
|
taskIsPending(previous.get(item.id)) && !taskIsPending(item))) resourcesDirty = true;
|
||||||
|
tasks.value = list.items;
|
||||||
|
taskQueryError.value = "";
|
||||||
|
const ids = [...new Set([detailID, deploymentID].filter(Boolean))];
|
||||||
|
await Promise.all(ids.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const snapshot = await readTask(id, signal);
|
||||||
|
if (stopped) return;
|
||||||
|
if (selectedID === id && showTaskDetails.value) {
|
||||||
|
taskDetails.value = snapshot.task;
|
||||||
|
taskAttempts.value = snapshot.attempts;
|
||||||
|
taskDeploymentReport.value = snapshot.report;
|
||||||
|
taskDetailsError.value = "";
|
||||||
|
}
|
||||||
|
if (deploymentResult.value?.taskID === id) {
|
||||||
|
if (deploymentResult.value.pending && !taskIsPending(snapshot.task)) resourcesDirty = true;
|
||||||
|
deploymentResult.value = {
|
||||||
|
...deploymentResult.value,
|
||||||
|
pending: taskIsPending(snapshot.task),
|
||||||
|
taskStatus: snapshot.task.status,
|
||||||
|
taskMessage: snapshot.task.message || "",
|
||||||
|
report: snapshot.report,
|
||||||
|
queryError: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (stopped) return;
|
||||||
|
const denied = error.status === 401 || error.status === 403;
|
||||||
|
const message = denied ? "当前已无权读取此任务,请联系项目管理员"
|
||||||
|
: "暂时无法查询任务状态,将自动重试;这不代表任务失败,请勿重复提交";
|
||||||
|
if (selectedID === id) {
|
||||||
|
taskDetailsError.value = message;
|
||||||
|
if (denied) {
|
||||||
|
taskDetails.value = null;
|
||||||
|
taskAttempts.value = [];
|
||||||
|
taskDeploymentReport.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deploymentResult.value?.taskID === id) {
|
||||||
|
deploymentResult.value.queryError = message;
|
||||||
|
if (denied) {
|
||||||
|
deploymentResult.value.report = null;
|
||||||
|
deploymentResult.value.taskMessage = "";
|
||||||
|
deploymentResult.value.taskStatus = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!stopped && resourcesDirty && refreshResources) {
|
||||||
|
await refreshResources(signal);
|
||||||
|
resourcesDirty = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!stopped) {
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
tasks.value = [];
|
||||||
|
taskDetails.value = null;
|
||||||
|
taskAttempts.value = [];
|
||||||
|
taskDeploymentReport.value = null;
|
||||||
|
if (deploymentResult.value) {
|
||||||
|
deploymentResult.value.report = null;
|
||||||
|
deploymentResult.value.taskMessage = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taskQueryError.value = "任务状态查询失败,将自动重试;请勿重复提交任务";
|
||||||
|
if (selectedID === detailID && detailID) taskDetailsError.value = taskQueryError.value;
|
||||||
|
if (deploymentID && deploymentResult.value?.taskID === deploymentID) {
|
||||||
|
deploymentResult.value.queryError = taskQueryError.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!stopped && selectedID === detailID) taskDetailsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTaskStatus() {
|
||||||
|
if (stopped) return Promise.resolve();
|
||||||
|
if (!inFlight) inFlight = poll().finally(() => { inFlight = null; });
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskDetails(item) {
|
||||||
|
selectedID = item.id;
|
||||||
|
showTaskDetails.value = true;
|
||||||
|
taskDetails.value = null;
|
||||||
|
taskAttempts.value = [];
|
||||||
|
taskDeploymentReport.value = null;
|
||||||
|
taskDetailsError.value = "";
|
||||||
|
taskDetailsLoading.value = true;
|
||||||
|
await refreshTaskStatus();
|
||||||
|
// 用户切换详情时,旧请求不能覆盖新选择;等待旧查询结束后再读取新任务。
|
||||||
|
if (!stopped && selectedID === item.id && taskDetailsLoading.value) await refreshTaskStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTaskDetails() {
|
||||||
|
selectedID = "";
|
||||||
|
showTaskDetails.value = false;
|
||||||
|
taskDetails.value = null;
|
||||||
|
taskAttempts.value = [];
|
||||||
|
taskDeploymentReport.value = null;
|
||||||
|
taskDetailsError.value = "";
|
||||||
|
taskDetailsLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { timer = window.setInterval(refreshTaskStatus, 3000); });
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
controller?.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { taskQueryError, taskDeploymentReport, refreshTaskStatus, openTaskDetails, closeTaskDetails };
|
||||||
|
}
|
||||||
226
frontend/src/views/Dashboard/TaskMonitor.test.js
Normal file
226
frontend/src/views/Dashboard/TaskMonitor.test.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { useDashboard } from "./Dashboard.js";
|
||||||
|
import { latestDeploymentReport } from "./TaskMonitor.js";
|
||||||
|
import { deploymentSummary } from "./DeploymentFeedback.js";
|
||||||
|
import { deferred, mountComposable } from "../../test-support/mount.js";
|
||||||
|
|
||||||
|
const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" };
|
||||||
|
const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 };
|
||||||
|
|
||||||
|
function taskFetcher(readTask, onPost = () => {}) {
|
||||||
|
return async (path, options = {}) => {
|
||||||
|
const task = readTask();
|
||||||
|
if (options.method === "POST") {
|
||||||
|
onPost(options);
|
||||||
|
return Response.json({ task, accepted: true }, { status: 202 });
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/tasks/${task.id}`) return Response.json(task);
|
||||||
|
if (path.endsWith("/attempts")) return Response.json({ items: [{ attempt_number: 1, status: task.status }] });
|
||||||
|
if (path.endsWith("/deployment-results")) return Response.json({ items: task.status === "running" ? [] : [target] });
|
||||||
|
if (path.startsWith("/api/v1/tasks?")) return Response.json({ items: [task] });
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
|
||||||
|
let current = { ...initialTask };
|
||||||
|
const posts = [];
|
||||||
|
const { state } = await mountComposable(t, useDashboard, taskFetcher(() => current, (options) => posts.push(options)));
|
||||||
|
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||||
|
assert.equal(posts.length, 1);
|
||||||
|
assert.match(posts[0].headers["Idempotency-Key"], /^deploy-domain-1-/);
|
||||||
|
assert.equal(state.deploymentResult.value.pending, true);
|
||||||
|
assert.equal(state.deploymentResult.value.running, false);
|
||||||
|
assert.equal(state.deploymentResult.value.error, "");
|
||||||
|
assert.equal(state.deploymentResult.value.report, null);
|
||||||
|
assert.match(state.notice.value, /任务已提交/);
|
||||||
|
current = { ...current, status: "failed", message: "站点校验未通过" };
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.deploymentResult.value.pending, false);
|
||||||
|
assert.equal(state.deploymentResult.value.taskStatus, "failed");
|
||||||
|
assert.equal(state.deploymentResult.value.report.results[0].status, "success");
|
||||||
|
assert.doesNotMatch(deploymentSummary(state.deploymentResult.value.report), /部署完成/);
|
||||||
|
assert.equal(posts.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("刷新页面从持久化任务恢复详情,关闭结果窗口不取消任务", async (t) => {
|
||||||
|
let current = { ...initialTask };
|
||||||
|
const { state } = await mountComposable(t, useDashboard, taskFetcher(() => current));
|
||||||
|
assert.equal(state.tasks.value[0].id, current.id);
|
||||||
|
await state.openTaskDetails(current);
|
||||||
|
assert.equal(state.taskDetails.value.status, "running");
|
||||||
|
state.deploymentResult.value = null;
|
||||||
|
current = { ...current, status: "success", message: "证书部署成功" };
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.taskDetails.value.status, "success");
|
||||||
|
assert.equal(state.taskDeploymentReport.value.results.length, 1);
|
||||||
|
state.closeTaskDetails();
|
||||||
|
assert.equal(state.taskDetails.value, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("部署结果只汇总最新尝试,不混用上一次失败或伪造站点验证", () => {
|
||||||
|
const task = { ...initialTask, status: "success" };
|
||||||
|
const attempts = [{ attempt_number: 1 }, { attempt_number: 2 }];
|
||||||
|
const report = latestDeploymentReport(task, attempts, [
|
||||||
|
{ ...target, status: "failed" }, { ...target, attempt_number: 2 },
|
||||||
|
]);
|
||||||
|
assert.equal(report.total, 1);
|
||||||
|
assert.equal(report.failed, 0);
|
||||||
|
assert.equal(report.verification_status, undefined);
|
||||||
|
assert.equal(latestDeploymentReport(task, attempts, [target]), null);
|
||||||
|
assert.equal(latestDeploymentReport(initialTask, attempts, [target]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("状态查询断线保留后台任务,恢复后继续读取且不会再次提交", async (t) => {
|
||||||
|
let unavailable = false;
|
||||||
|
let posts = 0;
|
||||||
|
const fetchTask = taskFetcher(() => initialTask, () => { posts += 1; });
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (unavailable && path.startsWith("/api/v1/tasks")) return Promise.reject(new Error("连接中断"));
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||||
|
unavailable = true;
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.deploymentResult.value.pending, true);
|
||||||
|
assert.equal(state.deploymentResult.value.taskStatus, "running");
|
||||||
|
assert.match(state.deploymentResult.value.queryError, /勿重复提交/);
|
||||||
|
unavailable = false;
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.deploymentResult.value.queryError, "");
|
||||||
|
assert.equal(posts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("任务轮询不重叠,卸载取消在途查询并停止计时器", async (t) => {
|
||||||
|
let block = false;
|
||||||
|
let requests = 0;
|
||||||
|
let aborted = false;
|
||||||
|
const fetchTask = taskFetcher(() => initialTask);
|
||||||
|
const { state, unmount, runIntervals } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (block && path.startsWith("/api/v1/tasks?")) {
|
||||||
|
requests += 1;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
options.signal.addEventListener("abort", () => { aborted = true; reject(new Error("已停止查询")); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
block = true;
|
||||||
|
const pending = state.refreshTaskStatus();
|
||||||
|
runIntervals();
|
||||||
|
runIntervals();
|
||||||
|
assert.equal(requests, 1);
|
||||||
|
unmount();
|
||||||
|
await pending;
|
||||||
|
runIntervals();
|
||||||
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(requests, 1);
|
||||||
|
assert.equal(state.taskQueryError.value, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("切换详情时迟到请求不能覆盖新任务", async (t) => {
|
||||||
|
const delayed = deferred();
|
||||||
|
let blocked = true;
|
||||||
|
const fetchTask = taskFetcher(() => initialTask);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, async (path, options) => {
|
||||||
|
if (path === "/api/v1/tasks/first" && blocked) return delayed.promise;
|
||||||
|
if (path === "/api/v1/tasks/second") return Response.json({ ...initialTask, id: "second" });
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
const first = state.openTaskDetails({ id: "first" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const second = state.openTaskDetails({ id: "second" });
|
||||||
|
blocked = false;
|
||||||
|
delayed.resolve(Response.json({ ...initialTask, id: "first" }));
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assert.equal(state.taskDetails.value.id, "second");
|
||||||
|
assert.equal(state.taskDetailsLoading.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("异步取消与重试只提示请求已接收,不提前宣布终态", async (t) => {
|
||||||
|
const fetchTask = taskFetcher(() => initialTask);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options = {}) => {
|
||||||
|
if (options.method === "DELETE") return Response.json({ task: { ...initialTask, status: "cancelling" } }, { status: 202 });
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
window.confirm = () => true;
|
||||||
|
await state.cancelTask(initialTask);
|
||||||
|
assert.match(state.notice.value, /等待任务停止和部署收尾/);
|
||||||
|
assert.doesNotMatch(state.notice.value, /任务已取消/);
|
||||||
|
await state.retryTask({ ...initialTask, status: "failed" });
|
||||||
|
assert.match(state.notice.value, /重试已提交/);
|
||||||
|
assert.doesNotMatch(state.notice.value, /已重新执行/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("代理返回非 JSON 时保留原状态,不泄露 HTML 或误报完成", async (t) => {
|
||||||
|
let invalid = false;
|
||||||
|
const fetchTask = taskFetcher(() => initialTask);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (invalid && path.startsWith("/api/v1/tasks/")) return Promise.resolve(new Response("<html>504</html>", { status: 504 }));
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
await state.openTaskDetails(initialTask);
|
||||||
|
invalid = true;
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.taskDetails.value.status, "running");
|
||||||
|
assert.match(state.taskDetailsError.value, /不代表任务失败/);
|
||||||
|
assert.doesNotMatch(state.taskDetailsError.value, /<html>|Unexpected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("任务与尝试读取遇到并发重试时不混用旧终态和新结果", async (t) => {
|
||||||
|
let retrying = false;
|
||||||
|
const current = { ...initialTask, status: "success" };
|
||||||
|
const fetchTask = taskFetcher(() => current);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (retrying && path.endsWith("/attempts")) {
|
||||||
|
return Promise.resolve(Response.json({ items: [{ attempt_number: 2, status: "running" }] }));
|
||||||
|
}
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
await state.openTaskDetails(current);
|
||||||
|
retrying = true;
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.match(state.taskDetailsError.value, /暂时无法查询/);
|
||||||
|
assert.equal(state.taskAttempts.value[0].attempt_number, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("项目撤权后清除缓存的任务详情和部署结果", async (t) => {
|
||||||
|
let denied = false;
|
||||||
|
const current = { ...initialTask, status: "success" };
|
||||||
|
const fetchTask = taskFetcher(() => current);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (denied && path.startsWith("/api/v1/tasks/")) {
|
||||||
|
return Promise.resolve(Response.json({ error: "无权访问此任务" }, { status: 403 }));
|
||||||
|
}
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
await state.openTaskDetails(current);
|
||||||
|
assert.equal(state.taskDeploymentReport.value.results.length, 1);
|
||||||
|
denied = true;
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.taskDetails.value, null);
|
||||||
|
assert.equal(state.taskDeploymentReport.value, null);
|
||||||
|
assert.deepEqual(state.taskAttempts.value, []);
|
||||||
|
assert.match(state.taskDetailsError.value, /无权读取/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {
|
||||||
|
let current = { ...initialTask };
|
||||||
|
let resourceReads = 0;
|
||||||
|
const fetchTask = taskFetcher(() => current);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (path === "/api/v1/domains") {
|
||||||
|
resourceReads += 1;
|
||||||
|
return Promise.resolve(Response.json({ items: [{ id: "domain-1", status: current.status === "success" ? "active" : "pending" }] }));
|
||||||
|
}
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
state.settingsForm.value.notification.sender_name = "正在编辑的名称";
|
||||||
|
assert.equal(state.domains.value[0].status, "pending");
|
||||||
|
current = { ...current, status: "success" };
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.domains.value[0].status, "active");
|
||||||
|
assert.equal(state.settingsForm.value.notification.sender_name, "正在编辑的名称");
|
||||||
|
assert.equal(resourceReads, 2);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user