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
4 changed files with 126 additions and 31 deletions
+20 -12
View File
@@ -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)
}
+88 -12
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) {
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) {
+16 -5
View File
@@ -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<T>(path: string, options?: RequestInit): 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_run_id?: number
}): Promise<GatewayStatusResponse> {
}): Promise<GatewayLogsResponse> {
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<GatewayStatusResponse>(`/api/gateway/status${queryString}`)
return request<GatewayLogsResponse>(`/api/gateway/logs${queryString}`)
}
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,
}
+2 -2
View File
@@ -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,
})