mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
+20
-12
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user