feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #46
This commit is contained in:
2026-08-31 23:17:23 +08:00
parent ab21344773
commit 4ed81f0030
30 changed files with 3527 additions and 91 deletions
+357 -2
View File
@@ -15,11 +15,17 @@ import (
const adminControlMaxBodyBytes = 1024
const adminScheduleMaxBodyBytes = 64 * 1024
const adminDefaultLogTail = 200
const adminMaxLogTail = 2000
type adminControlRequest struct {
Force bool `json:"force"`
}
type adminTaskRequest struct {
TaskID string `json:"task_id"`
}
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -36,7 +42,16 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/v1/release",
"/v1/resources",
"/openapi.yaml",
"/admin/dashboard/",
"/admin/schedules",
"/admin/tasks",
"/admin/tasks/status",
"/admin/tasks/logs",
"/admin/diagnostics",
"/admin/logs",
"/admin/parse/status",
"/admin/parse/text-units",
"/admin/parse/errors",
"/admin/translation/tasks",
"/admin/translation/handoff",
"/admin/translation/status",
@@ -53,6 +68,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/schedule-update",
"/admin/control/schedule-remove",
"/admin/control/schedule-run",
"/admin/control/task-cancel",
"/admin/control/translation-task-update",
"/admin/control/translation-worker-run",
"/admin/control/translation-proofread",
@@ -85,6 +101,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminScheduleControl(w, r, action)
return
}
if action == "task-cancel" {
s.handleAdminTaskCancel(w, r)
return
}
if action == "translation-task-update" {
s.handleAdminTranslationTaskUpdate(w, r)
return
@@ -188,8 +208,8 @@ func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status")
if err := validateTranslationTaskUpdateParams(params); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error())
return
}
result, err := backend.TranslationTaskUpdate(r.Context(), params)
@@ -332,6 +352,253 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
if s.backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat diagnostics backend is unavailable")
return
}
result, err := s.backend.DaemonDoctor(r.Context())
if err != nil {
s.writeControlBackendError(w, "diagnostics", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(DaemonLogsBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat log backend is unavailable")
return
}
tail, err := adminLogTail(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_log_query", err.Error())
return
}
result, err := backend.DaemonLogs(r.Context(), tail)
if err != nil {
s.writeControlBackendError(w, "daemon-logs", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
result, err := backend.TaskList(r.Context())
if err != nil {
s.writeControlBackendError(w, "task-list", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
taskID, err := adminTaskIDQuery(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error())
return
}
result, err := backend.TaskStatus(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
taskID, err := adminTaskIDQuery(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error())
return
}
result, err := backend.TaskLogs(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-logs", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminParseStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ParseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable")
return
}
result, err := backend.ParseStatus(r.Context())
if err != nil {
s.writeControlBackendError(w, "parse-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminParseTextUnits(w http.ResponseWriter, r *http.Request) {
s.handleAdminParseTextUnitQuery(w, r, false)
}
func (s *Server) handleAdminParseErrors(w http.ResponseWriter, r *http.Request) {
s.handleAdminParseTextUnitQuery(w, r, true)
}
func (s *Server) handleAdminParseTextUnitQuery(w http.ResponseWriter, r *http.Request, errorsOnly bool) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ParseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable")
return
}
query, err := parseTextUnitQueryParams(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_parse_query", err.Error())
return
}
var result json.RawMessage
if errorsOnly {
result, err = backend.ParseErrors(r.Context(), query)
} else {
result, err = backend.ParseTextUnits(r.Context(), query)
}
if err != nil {
action := "parse-text-units"
if errorsOnly {
action = "parse-errors"
}
s.writeControlBackendError(w, action, err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskCancel(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
var params adminTaskRequest
if !decodeAdminTaskJSON(w, r, &params) {
return
}
taskID := strings.TrimSpace(params.TaskID)
if taskID == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_params", "task cancel requires task_id")
return
}
result, err := backend.TaskCancel(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-cancel", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "task-cancel",
RPCMethod: "task.cancel",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -480,6 +747,46 @@ func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListP
return params, nil
}
func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) {
query := r.URL.Query()
params := backendrpc.TextUnitQueryParams{
Destination: strings.TrimSpace(query.Get("destination")),
PathPattern: strings.TrimSpace(query.Get("path_pattern")),
ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")),
FieldPath: strings.TrimSpace(query.Get("field_path")),
Format: strings.TrimSpace(query.Get("format")),
}
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
offset, err := strconv.ParseInt(raw, 10, 32)
if err != nil || offset < 0 {
return backendrpc.TextUnitQueryParams{}, errors.New("offset must be a non-negative integer")
}
params.Offset = int(offset)
}
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
limit, err := strconv.ParseInt(raw, 10, 32)
if err != nil || limit < 1 || limit > 1000 {
return backendrpc.TextUnitQueryParams{}, errors.New("limit must be in 1..=1000")
}
params.Limit = int(limit)
}
if raw := strings.TrimSpace(query.Get("path_id")); raw != "" {
pathID, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return backendrpc.TextUnitQueryParams{}, errors.New("path_id must be a signed integer")
}
params.PathID = &pathID
}
if raw := strings.TrimSpace(query.Get("class_id")); raw != "" {
classID, err := strconv.Atoi(raw)
if err != nil {
return backendrpc.TextUnitQueryParams{}, errors.New("class_id must be a signed integer")
}
params.ClassID = &classID
}
return params, nil
}
func firstTrimmedQuery(query url.Values, keys ...string) string {
for _, key := range keys {
values := query[key]
@@ -493,6 +800,26 @@ func firstTrimmedQuery(query url.Values, keys ...string) string {
return ""
}
func adminLogTail(r *http.Request) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("tail"))
if raw == "" {
return adminDefaultLogTail, nil
}
tail, err := strconv.Atoi(raw)
if err != nil || tail < 1 || tail > adminMaxLogTail {
return 0, errors.New("tail must be in 1..=2000")
}
return tail, nil
}
func adminTaskIDQuery(r *http.Request) (string, error) {
taskID := strings.TrimSpace(r.URL.Query().Get("task_id"))
if taskID == "" {
return "", errors.New("task_id is required")
}
return taskID, nil
}
func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(ScheduleBackend)
if !ok || backend == nil {
@@ -567,6 +894,34 @@ func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target a
return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request")
}
func decodeAdminTaskJSON(w http.ResponseWriter, r *http.Request, target any) bool {
return decodeAdminJSON(w, r, target, "invalid_task_params", "task request")
}
func validateTranslationTaskUpdateParams(params backendrpc.TranslationTaskUpdateParams) error {
if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" {
return errors.New("translation task update requires task_id and status")
}
if len(params.TranslationResults) == 0 {
return nil
}
if strings.TrimSpace(params.Status) != "completed" {
return errors.New("translation_results can only be submitted with completed status")
}
seen := make(map[string]struct{}, len(params.TranslationResults))
for _, result := range params.TranslationResults {
unitID := strings.TrimSpace(result.UnitID)
if unitID == "" {
return errors.New("translation_results unit_id is required")
}
if _, ok := seen[unitID]; ok {
return errors.New("translation_results unit_id must be unique")
}
seen[unitID] = struct{}{}
}
return nil
}
func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error {
if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) {
return errors.New("translation worker concurrency must be in 1..=256")
+307 -2
View File
@@ -560,6 +560,11 @@ type fakeBackend struct {
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
}
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
@@ -585,10 +590,49 @@ func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return f.manifest, nil
}
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
if f.daemonLogs != nil {
return f.daemonLogs, nil
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (f *fakeBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
if f.taskList != nil {
return f.taskList, nil
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (f *fakeBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
if f.taskStatus != nil {
out := *f.taskStatus
out.ID = taskID
return &out, nil
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (f *fakeBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
if f.taskLogs != nil {
out := *f.taskLogs
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
if f.taskCancel != nil {
out := *f.taskCancel
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
type controlBackend struct {
*fakeBackend
calls []string
parseTextUnitQueries []backendrpc.TextUnitQueryParams
parseErrorQueries []backendrpc.TextUnitQueryParams
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
translationTaskListParams []backendrpc.TranslationTaskListParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
@@ -629,8 +673,70 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
}
func (b *controlBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
b.calls = append(b.calls, "daemon.doctor")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonDoctor(ctx)
}
return &backendrpc.DoctorReport{Command: "doctor", Status: "ok", Message: "healthy", Healthy: true}, nil
}
func (b *controlBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
b.calls = append(b.calls, "daemon.logs")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonLogs(ctx, tail)
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (b *controlBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
b.calls = append(b.calls, "task.list")
if b.fakeBackend != nil {
return b.fakeBackend.TaskList(ctx)
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (b *controlBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
b.calls = append(b.calls, "task.status")
if b.fakeBackend != nil {
return b.fakeBackend.TaskStatus(ctx, taskID)
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (b *controlBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
b.calls = append(b.calls, "task.logs")
if b.fakeBackend != nil {
return b.fakeBackend.TaskLogs(ctx, taskID)
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (b *controlBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
b.calls = append(b.calls, "task.cancel")
if b.fakeBackend != nil {
return b.fakeBackend.TaskCancel(ctx, taskID)
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
func (b *controlBackend) ParseStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.status")
return json.RawMessage(`{"available":true,"status":"parsed"}`), nil
}
func (b *controlBackend) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.text_units")
b.parseTextUnitQueries = append(b.parseTextUnitQueries, query)
return json.RawMessage(`{"available":true,"entries":[{"id":"direct:a#unit:0","destination":"Bundle/a.bundle","source_text":"source","field_path":"Scenario.Message"}]}`), nil
}
func (b *controlBackend) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.errors")
b.parseErrorQueries = append(b.parseErrorQueries, query)
return json.RawMessage(`{"available":true,"entries":[]}`), nil
}
func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.task.update")
b.translationTaskUpdates = append(b.translationTaskUpdates, params)
return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil
}
@@ -1392,6 +1498,13 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") {
t.Fatalf("openapi missing launcher bootstrap")
}
if !strings.Contains(rr.Body.String(), "/admin/dashboard/") ||
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "task-cancel") {
t.Fatalf("openapi missing dashboard/task admin routes")
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil))
@@ -1410,11 +1523,187 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
}
links := strings.Join(admin.Links, "\n")
if !strings.Contains(links, "/admin/translation/tasks") ||
!strings.Contains(links, "/admin/translation/handoff") {
!strings.Contains(links, "/admin/translation/handoff") ||
!strings.Contains(links, "/admin/dashboard/") ||
!strings.Contains(links, "/admin/parse/text-units") ||
!strings.Contains(links, "/admin/tasks/logs") {
t.Fatalf("admin links=%v", admin.Links)
}
}
func TestAdminDashboardServesStaticAssetsWithoutAdminToken(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("dashboard status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("dashboard Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "connect-src") {
t.Fatalf("dashboard CSP=%q", rr.Header().Get("Content-Security-Policy"))
}
if !strings.Contains(rr.Body.String(), "bat-api Dashboard") {
t.Fatalf("dashboard body missing title")
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/app.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("app.js status=%d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "translation-worker-run") {
t.Fatalf("app.js missing dashboard control wiring")
}
}
func TestAdminTaskAndDiagnosticsEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
message := "task running"
backend := &controlBackend{fakeBackend: &fakeBackend{
doctor: &backendrpc.DoctorReport{
Command: "doctor",
Status: "ok",
Message: "healthy",
Healthy: true,
Checks: []backendrpc.DoctorCheck{{Name: "socket", OK: true, Message: "ok"}},
},
taskList: &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{{
ID: "task-sync-1",
Kind: "resource.sync",
Status: "running",
Message: &message,
}}},
}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
method string
path string
wantCall string
}{
{name: "diagnostics", method: http.MethodGet, path: "/admin/diagnostics", wantCall: "daemon.doctor"},
{name: "daemon logs", method: http.MethodGet, path: "/admin/logs?tail=20", wantCall: "daemon.logs"},
{name: "task list", method: http.MethodGet, path: "/admin/tasks", wantCall: "task.list"},
{name: "task status", method: http.MethodGet, path: "/admin/tasks/status?task_id=task-sync-1", wantCall: "task.status"},
{name: "task logs", method: http.MethodGet, path: "/admin/tasks/logs?task_id=task-sync-1", wantCall: "task.logs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(tc.method, tc.path, nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
request := httptest.NewRequest(http.MethodGet, "/admin/logs?tail=0", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid logs status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks/status", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid task status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminParseEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "parse-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
path string
wantCall string
}{
{name: "parse status", path: "/admin/parse/status", wantCall: "parse.status"},
{name: "parse text units", path: "/admin/parse/text-units?offset=1&limit=25&destination=Bundle%2Fa.bundle&path_id=42&class_id=114&field_path=Scenario.Message&format=plain", wantCall: "parse.text_units"},
{name: "parse errors", path: "/admin/parse/errors?destination=Bundle%2Fa.bundle&limit=10", wantCall: "parse.errors"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tc.path, nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
if len(backend.parseTextUnitQueries) != 1 {
t.Fatalf("parse text unit queries=%#v", backend.parseTextUnitQueries)
}
query := backend.parseTextUnitQueries[0]
if query.Offset != 1 ||
query.Limit != 25 ||
query.Destination != "Bundle/a.bundle" ||
query.PathID == nil || *query.PathID != 42 ||
query.ClassID == nil || *query.ClassID != 114 ||
query.FieldPath != "Scenario.Message" ||
query.Format != "plain" {
t.Fatalf("query=%#v", query)
}
if len(backend.parseErrorQueries) != 1 || backend.parseErrorQueries[0].Destination != "Bundle/a.bundle" {
t.Fatalf("parse error queries=%#v", backend.parseErrorQueries)
}
request := httptest.NewRequest(http.MethodGet, "/admin/parse/text-units?limit=0", nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid parse query status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/parse/status", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated parse status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
@@ -1436,7 +1725,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
@@ -1464,6 +1754,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
})
}
if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
@@ -1472,6 +1769,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":"textunit/v-current/Scenario","status":"failed","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation result status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
+1 -1
View File
@@ -122,7 +122,7 @@ func (c *Config) Normalize() error {
if c.AuthQueryParam == "" {
c.AuthQueryParam = "bat_token"
}
c.AuthExemptPaths = normalizePathList(c.AuthExemptPaths)
c.AuthExemptPaths = normalizePathList(append(c.AuthExemptPaths, dashboardAuthExemptPaths()...))
if c.RateLimitRPS < 0 {
return fmt.Errorf("rate limit rps must be >= 0")
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
"strings"
dashboard "bat-api/web"
)
const adminDashboardPath = "/admin/dashboard"
var adminDashboardFileServer = http.FileServer(http.FS(dashboard.Assets))
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == adminDashboardPath {
http.Redirect(w, r, adminDashboardPath+"/", http.StatusMovedPermanently)
return
}
if !strings.HasPrefix(r.URL.Path, adminDashboardPath+"/") {
http.NotFound(w, r)
return
}
http.StripPrefix(adminDashboardPath+"/", adminDashboardFileServer).ServeHTTP(w, r)
}
func dashboardAuthExemptPaths() []string {
return []string{adminDashboardPath, adminDashboardPath + "/"}
}
func isAdminDashboardPath(path string) bool {
return path == adminDashboardPath || strings.HasPrefix(path, adminDashboardPath+"/")
}
+5 -1
View File
@@ -51,7 +51,11 @@ func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
if isAdminDashboardPath(r.URL.Path) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' http: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
} else {
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
}
next.ServeHTTP(w, r)
})
}
+212 -2
View File
@@ -109,12 +109,210 @@ paths:
responses:
"200":
description: OpenAPI YAML.
/admin/dashboard/:
get:
summary: Embedded bat-api dashboard
security: []
responses:
"200":
description: Static dashboard HTML.
/admin/:
get:
summary: Admin control entry
responses:
"200":
description: Admin links and allowlisted control actions.
/admin/diagnostics:
get:
summary: Read Rust daemon doctor diagnostics
responses:
"200":
description: Current daemon.doctor report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat diagnostics backend is unavailable.
/admin/logs:
get:
summary: Read Rust daemon log tail
parameters:
- name: tail
in: query
schema:
type: integer
minimum: 1
maximum: 2000
responses:
"200":
description: Current daemon.logs report.
"400":
description: Invalid log query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat log backend is unavailable.
/admin/tasks:
get:
summary: List Rust-owned async daemon tasks
responses:
"200":
description: Current task.list report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/status:
get:
summary: Read one Rust-owned async daemon task
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.status report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/logs:
get:
summary: Read one Rust-owned async daemon task log
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.logs report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/parse/status:
get:
summary: Read Rust-owned parse/TextUnit index status
responses:
"200":
description: Current parse.status report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/text-units:
get:
summary: Query Rust-owned TextUnit index entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.text_units report.
"400":
description: Invalid TextUnit query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/errors:
get:
summary: Query Rust-owned TextUnit extraction diagnostics
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.errors report.
"400":
description: Invalid parse error query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/schedules:
get:
summary: List Rust-owned resource workflow schedules
@@ -232,7 +430,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -285,7 +483,19 @@ paths:
type: string
provider:
type: string
enum: [mock, crowdin]
translation_results:
type: array
items:
type: object
required: [unit_id, source_text, translated_text]
additionalProperties: false
properties:
unit_id:
type: string
source_text:
type: string
translated_text:
type: string
fixture_path:
type: string
concurrency:
+38
View File
@@ -39,6 +39,29 @@ type ControlBackend interface {
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
}
// DaemonLogsBackend exposes the Rust daemon log tail for an authenticated
// dashboard. It remains read-only and never opens log files from Go.
type DaemonLogsBackend interface {
DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error)
}
// TaskBackend exposes Rust-owned async task state to the dashboard. Go only
// forwards read/cancel requests and does not create generic tasks.
type TaskBackend interface {
TaskList(ctx context.Context) (*backendrpc.TaskList, error)
TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error)
TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error)
TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error)
}
// ParseBackend exposes existing Rust TextUnit index queries to the dashboard.
// It is read-only and does not expand parser coverage.
type ParseBackend interface {
ParseStatus(ctx context.Context) (json.RawMessage, error)
ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
}
// ScheduleBackend exposes the Rust-owned schedule store to an authenticated
// dashboard. The JSON result remains Rust's report shape so the API does not
// duplicate schedule state or invent a second schema.
@@ -97,6 +120,9 @@ func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
return r.Client.DaemonRefresh(ctx, force)
}
func (r RPCClient) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
return r.Client.DaemonLogs(ctx, tail)
}
func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.ResourceSync(ctx, force)
}
@@ -109,6 +135,18 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.CatalogRefresh(ctx, force)
}
func (r RPCClient) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
return r.Client.TaskList(ctx)
}
func (r RPCClient) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
return r.Client.TaskStatus(ctx, taskID)
}
func (r RPCClient) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
return r.Client.TaskLogs(ctx, taskID)
}
func (r RPCClient) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
return r.Client.TaskCancel(ctx, taskID)
}
func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
return r.Client.ScheduleListFiltered(ctx, params)
}
+19
View File
@@ -61,6 +61,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
mux.HandleFunc("/admin/dashboard/", s.handleAdminDashboard)
mux.HandleFunc("/admin/diagnostics", s.handleAdminDiagnostics)
mux.HandleFunc("/admin/logs", s.handleAdminLogs)
mux.HandleFunc("/admin/tasks", s.handleAdminTasks)
mux.HandleFunc("/admin/tasks/status", s.handleAdminTaskStatus)
mux.HandleFunc("/admin/tasks/logs", s.handleAdminTaskLogs)
mux.HandleFunc("/admin/parse/status", s.handleAdminParseStatus)
mux.HandleFunc("/admin/parse/text-units", s.handleAdminParseTextUnits)
mux.HandleFunc("/admin/parse/errors", s.handleAdminParseErrors)
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
@@ -128,7 +138,16 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/" + ClientPatchHost + "/...",
"/" + ServerInfoHost + "/...",
"/openapi.yaml",
"/admin/dashboard/",
"/admin/",
"/admin/diagnostics",
"/admin/logs",
"/admin/tasks",
"/admin/tasks/status",
"/admin/tasks/logs",
"/admin/parse/status",
"/admin/parse/text-units",
"/admin/parse/errors",
"/admin/schedules",
"/admin/translation/tasks",
"/admin/translation/handoff",
+16 -5
View File
@@ -264,13 +264,24 @@ type ScheduleRunParams struct {
MaxRuns *uint64 `json:"max_runs,omitempty"`
}
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
// TextUnit result accepted by Rust translation.task.update.
type TranslationTaskUnitResultParam struct {
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
}
// TranslationTaskUpdateParams is used by translation.task.update to persist
// provider worker state for one task in the current official release.
// provider worker state and optional manual-review text for one task in the
// current official release.
type TranslationTaskUpdateParams struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
FailureReason string `json:"failure_reason,omitempty"`
ProviderRunID string `json:"provider_run_id,omitempty"`
TaskID string `json:"task_id"`
Status string `json:"status"`
FailureReason string `json:"failure_reason,omitempty"`
ProviderRunID string `json:"provider_run_id,omitempty"`
Provider string `json:"provider,omitempty"`
TranslationResults []TranslationTaskUnitResultParam `json:"translation_results,omitempty"`
}
// TranslationTaskListParams filters the Rust-owned translation task queue.
+16 -6
View File
@@ -434,9 +434,14 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
t.Fatalf("decode params: %v", err)
}
if params.TaskID != "textunit/v-current/Scenario" ||
params.Status != "failed" ||
params.FailureReason != "provider rejected payload" ||
params.ProviderRunID != "provider-run-1" {
params.Status != "completed" ||
params.FailureReason != "" ||
params.ProviderRunID != "provider-run-1" ||
params.Provider != "manual" ||
len(params.TranslationResults) != 1 ||
params.TranslationResults[0].UnitID != "direct:a#unit:0" ||
params.TranslationResults[0].SourceText != "source" ||
params.TranslationResults[0].TranslatedText != "译文" {
t.Fatalf("params = %#v", params)
}
return testResponse{
@@ -444,16 +449,21 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
OK: true,
Status: "ok",
RequestID: "req-test-translation-update",
Data: map[string]any{"task_status": "failed"},
Data: map[string]any{"task_status": "completed"},
},
}
})
raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{
TaskID: "textunit/v-current/Scenario",
Status: "failed",
FailureReason: "provider rejected payload",
Status: "completed",
ProviderRunID: "provider-run-1",
Provider: "manual",
TranslationResults: []TranslationTaskUnitResultParam{{
UnitID: "direct:a#unit:0",
SourceText: "source",
TranslatedText: "译文",
}},
})
if err != nil {
t.Fatalf("TranslationTaskUpdate error: %v", err)