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")