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
This commit is contained in:
wenjie 2026-03-13 17:58:20 +08:00 committed by GitHub
parent 6b72326be1
commit c69c48ad46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 31 deletions

View file

@ -52,6 +52,7 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) 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/logs/clear", h.handleGatewayClearLogs)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) 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 // GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) 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{} data := map[string]any{}
cfg, cfgErr := config.LoadConfig(h.configPath) cfg, cfgErr := config.LoadConfig(h.configPath)
configDefaultModel := "" configDefaultModel := ""
@ -661,16 +662,22 @@ func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[strin
} }
} }
if includeLogs {
appendGatewayLogs(r, data)
}
return data return data
} }
// appendGatewayLogs reads log_offset and log_run_id query params from the request // handleGatewayLogs returns buffered gateway logs, optionally incrementally.
// and populates the response data map with incremental log lines. //
func appendGatewayLogs(r *http.Request, data map[string]any) { // 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 clientOffset := 0
clientRunID := -1 clientRunID := -1
@ -692,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
data["logs"] = []string{} data["logs"] = []string{}
data["log_total"] = 0 data["log_total"] = 0
data["log_run_id"] = 0 data["log_run_id"] = 0
return return data
} }
// If runID changed, reset offset to get all logs from new run // 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["logs"] = lines
data["log_total"] = total data["log_total"] = total
data["log_run_id"] = runID data["log_run_id"] = runID
return data
} }
// handleGatewayEvents serves an SSE stream of gateway state change events. // 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. // currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string { func (h *Handler) currentGatewayStatus() string {
data := h.gatewayStatusData(nil, false) data := h.gatewayStatusData()
encoded, _ := json.Marshal(data) encoded, _ := json.Marshal(data)
return string(encoded) return string(encoded)
} }

View file

@ -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) { func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath) h := NewHandler(configPath)
@ -743,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
} }
statusRec := httptest.NewRecorder() logsRec := httptest.NewRecorder()
statusReq := httptest.NewRequest( logsReq := httptest.NewRequest(
http.MethodGet, 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, nil,
) )
mux.ServeHTTP(statusRec, statusReq) mux.ServeHTTP(logsRec, logsReq)
if statusRec.Code != http.StatusOK { if logsRec.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK) t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
} }
var statusBody map[string]any var logsBody map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil { if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
t.Fatalf("unmarshal status response: %v", err) t.Fatalf("unmarshal logs response: %v", err)
} }
logs, ok := statusBody["logs"].([]any) logs, ok := logsBody["logs"].([]any)
if !ok { 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 { if len(logs) != 0 {
t.Fatalf("logs len = %d, want 0", len(logs)) 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) 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) { func TestFindPicoclawBinary_EnvOverride(t *testing.T) {

View file

@ -8,10 +8,13 @@ interface GatewayStatusResponse {
pid?: number pid?: number
boot_default_model?: string boot_default_model?: string
config_default_model?: string config_default_model?: string
[key: string]: unknown
}
interface GatewayLogsResponse {
logs?: string[] logs?: string[]
log_total?: number log_total?: number
log_run_id?: number log_run_id?: number
[key: string]: unknown
} }
interface GatewayActionResponse { interface GatewayActionResponse {
@ -31,10 +34,14 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
export async function getGatewayStatus(options?: { export async function getGatewayStatus(): Promise<GatewayStatusResponse> {
return request<GatewayStatusResponse>("/api/gateway/status")
}
export async function getGatewayLogs(options?: {
log_offset?: number log_offset?: number
log_run_id?: number log_run_id?: number
}): Promise<GatewayStatusResponse> { }): Promise<GatewayLogsResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (options?.log_offset !== undefined) { if (options?.log_offset !== undefined) {
params.set("log_offset", options.log_offset.toString()) 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()) params.set("log_run_id", options.log_run_id.toString())
} }
const queryString = params.toString() ? `?${params.toString()}` : "" const queryString = params.toString() ? `?${params.toString()}` : ""
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`) return request<GatewayLogsResponse>(`/api/gateway/logs${queryString}`)
} }
export async function startGateway(): Promise<GatewayActionResponse> { export async function startGateway(): Promise<GatewayActionResponse> {
@ -70,4 +77,8 @@ export async function clearGatewayLogs(): Promise<GatewayActionResponse> {
}) })
} }
export type { GatewayStatusResponse, GatewayActionResponse } export type {
GatewayStatusResponse,
GatewayLogsResponse,
GatewayActionResponse,
}

View file

@ -1,7 +1,7 @@
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway" import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway" import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() { export function useGatewayLogs() {
@ -49,7 +49,7 @@ export function useGatewayLogs() {
const requestToken = syncTokenRef.current const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({ const data = await getGatewayLogs({
log_offset: requestOffset, log_offset: requestOffset,
log_run_id: requestRunId, log_run_id: requestRunId,
}) })