From c69c48ad464db5a6d17e9d8025726926df4171f1 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 13 Mar 2026 17:58:20 +0800 Subject: [PATCH] refactor(web): split gateway logs out of the status endpoint (#1504) - add a dedicated /api/gateway/logs endpoint for incremental log polling - keep /api/gateway/status focused on runtime and health data only - update frontend log fetching to use the new API and add backend tests covering the status/logs separation and cleared-log behavior --- web/backend/api/gateway.go | 32 ++++--- web/backend/api/gateway_test.go | 100 ++++++++++++++++++--- web/frontend/src/api/gateway.ts | 21 +++-- web/frontend/src/hooks/use-gateway-logs.ts | 4 +- 4 files changed, 126 insertions(+), 31 deletions(-) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 95b482ce0..1813cac92 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -52,6 +52,7 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) + mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs) mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) @@ -560,16 +561,16 @@ func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) }) } -// handleGatewayStatus returns the gateway run status, health info, and logs. +// handleGatewayStatus returns the gateway run status and health info. // // GET /api/gateway/status func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { - data := h.gatewayStatusData(r, true) + data := h.gatewayStatusData() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } -func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[string]any { +func (h *Handler) gatewayStatusData() map[string]any { data := map[string]any{} cfg, cfgErr := config.LoadConfig(h.configPath) configDefaultModel := "" @@ -661,16 +662,22 @@ func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[strin } } - if includeLogs { - appendGatewayLogs(r, data) - } - return data } -// appendGatewayLogs reads log_offset and log_run_id query params from the request -// and populates the response data map with incremental log lines. -func appendGatewayLogs(r *http.Request, data map[string]any) { +// handleGatewayLogs returns buffered gateway logs, optionally incrementally. +// +// GET /api/gateway/logs +func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) { + data := gatewayLogsData(r) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// gatewayLogsData reads log_offset and log_run_id query params from the request +// and returns incremental log lines. +func gatewayLogsData(r *http.Request) map[string]any { + data := map[string]any{} clientOffset := 0 clientRunID := -1 @@ -692,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) { data["logs"] = []string{} data["log_total"] = 0 data["log_run_id"] = 0 - return + return data } // If runID changed, reset offset to get all logs from new run @@ -709,6 +716,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) { data["logs"] = lines data["log_total"] = total data["log_run_id"] = runID + return data } // handleGatewayEvents serves an SSE stream of gateway state change events. @@ -751,7 +759,7 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) { // currentGatewayStatus returns the current gateway status as a JSON string. func (h *Handler) currentGatewayStatus() string { - data := h.gatewayStatusData(nil, false) + data := h.gatewayStatusData() encoded, _ := json.Marshal(data) return string(encoded) } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index fe3fccdee..06803722d 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -707,6 +707,79 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing. } } +func TestGatewayStatusExcludesLogsFields(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if _, ok := body["logs"]; ok { + t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"]) + } + if _, ok := body["log_total"]; ok { + t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"]) + } + if _, ok := body["log_run_id"]; ok { + t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"]) + } +} + +func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.logs.Clear() + gateway.logs.Append("first line") + gateway.logs.Append("second line") + runID := gateway.logs.RunID() + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID), + nil, + ) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal logs response: %v", err) + } + + logs, ok := body["logs"].([]any) + if !ok { + t.Fatalf("logs missing or not array: %#v", body["logs"]) + } + if len(logs) != 1 || logs[0] != "second line" { + t.Fatalf("logs = %#v, want [\"second line\"]", logs) + } + if got := body["log_total"]; got != float64(2) { + t.Fatalf("log_total = %#v, want 2", got) + } + if got := body["log_run_id"]; got != float64(runID) { + t.Fatalf("log_run_id = %#v, want %d", got, runID) + } +} + func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -743,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) } - statusRec := httptest.NewRecorder() - statusReq := httptest.NewRequest( + logsRec := httptest.NewRecorder() + logsReq := httptest.NewRequest( http.MethodGet, - "/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), + "/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), nil, ) - mux.ServeHTTP(statusRec, statusReq) + mux.ServeHTTP(logsRec, logsReq) - if statusRec.Code != http.StatusOK { - t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK) + if logsRec.Code != http.StatusOK { + t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK) } - var statusBody map[string]any - if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil { - t.Fatalf("unmarshal status response: %v", err) + var logsBody map[string]any + if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil { + t.Fatalf("unmarshal logs response: %v", err) } - logs, ok := statusBody["logs"].([]any) + logs, ok := logsBody["logs"].([]any) if !ok { - t.Fatalf("logs missing or not array: %#v", statusBody["logs"]) + t.Fatalf("logs missing or not array: %#v", logsBody["logs"]) } if len(logs) != 0 { t.Fatalf("logs len = %d, want 0", len(logs)) } - if got := statusBody["log_total"]; got != float64(0) { + if got := logsBody["log_total"]; got != float64(0) { t.Fatalf("log_total = %#v, want 0", got) } + if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] { + t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"]) + } } func TestFindPicoclawBinary_EnvOverride(t *testing.T) { diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 1688a5278..9e02a02b5 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -8,10 +8,13 @@ interface GatewayStatusResponse { pid?: number boot_default_model?: string config_default_model?: string + [key: string]: unknown +} + +interface GatewayLogsResponse { logs?: string[] log_total?: number log_run_id?: number - [key: string]: unknown } interface GatewayActionResponse { @@ -31,10 +34,14 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getGatewayStatus(options?: { +export async function getGatewayStatus(): Promise { + return request("/api/gateway/status") +} + +export async function getGatewayLogs(options?: { log_offset?: number log_run_id?: number -}): Promise { +}): Promise { const params = new URLSearchParams() if (options?.log_offset !== undefined) { params.set("log_offset", options.log_offset.toString()) @@ -43,7 +50,7 @@ export async function getGatewayStatus(options?: { params.set("log_run_id", options.log_run_id.toString()) } const queryString = params.toString() ? `?${params.toString()}` : "" - return request(`/api/gateway/status${queryString}`) + return request(`/api/gateway/logs${queryString}`) } export async function startGateway(): Promise { @@ -70,4 +77,8 @@ export async function clearGatewayLogs(): Promise { }) } -export type { GatewayStatusResponse, GatewayActionResponse } +export type { + GatewayStatusResponse, + GatewayLogsResponse, + GatewayActionResponse, +} diff --git a/web/frontend/src/hooks/use-gateway-logs.ts b/web/frontend/src/hooks/use-gateway-logs.ts index 593e90b26..15cbca4ae 100644 --- a/web/frontend/src/hooks/use-gateway-logs.ts +++ b/web/frontend/src/hooks/use-gateway-logs.ts @@ -1,7 +1,7 @@ import { useAtomValue } from "jotai" import { useEffect, useRef, useState } from "react" -import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway" +import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway" import { gatewayAtom } from "@/store/gateway" export function useGatewayLogs() { @@ -49,7 +49,7 @@ export function useGatewayLogs() { const requestToken = syncTokenRef.current const requestOffset = logOffsetRef.current const requestRunId = logRunIdRef.current - const data = await getGatewayStatus({ + const data = await getGatewayLogs({ log_offset: requestOffset, log_run_id: requestRunId, })