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
+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()