mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
+357
-2
@@ -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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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+"/")
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user