diff --git a/internal/api/handlers_jobs.go b/internal/api/handlers_jobs.go index 03b98a7..ebf3118 100644 --- a/internal/api/handlers_jobs.go +++ b/internal/api/handlers_jobs.go @@ -166,7 +166,11 @@ func (h *JobHandler) GetLog(w http.ResponseWriter, r *http.Request) { count, _ := logRepo.CountByJobID(id) w.Header().Set("X-Total-Count", fmt.Sprintf("%d", count)) - writeJSON(w, logs) + if logs == nil { + writeJSON(w, []any{}) + } else { + writeJSON(w, logs) + } } func (h *JobHandler) DownloadLog(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router.go b/internal/api/router.go index 56c0088..9a68183 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2,7 +2,9 @@ package api import ( "database/sql" + "log/slog" "net/http" + "runtime/debug" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -26,7 +28,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) - r.Use(middleware.Recoverer) + r.Use(recoverer) s := &Server{router: r, cfg: cfg, engine: engine} @@ -101,3 +103,22 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } + +func recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + slog.Error("panic recovered", + "error", err, + "stack", string(debug.Stack()), + "method", r.Method, + "path", r.URL.Path, + ) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f9f68f5..654c9bb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -5,6 +5,10 @@ interface ApiOptions { body?: unknown; } +function isArray(v: unknown): v is unknown[] { + return Array.isArray(v); +} + export async function api(path: string, opts: ApiOptions = {}): Promise { const { method = 'GET', body } = opts; const res = await fetch(`${BASE}${path}`, { @@ -18,7 +22,10 @@ export async function api(path: string, opts: ApiOptions = {}): Promise { throw new Error((err as { error?: string }).error || 'Request failed'); } if (res.status === 204) return undefined as T; - return res.json(); + const data = await res.json().catch(() => null); + if (data === null) return undefined as T; + if (isArray(data) && !data.length && data[0] === undefined) return [] as unknown as T; + return data as T; } export async function apiRaw(path: string): Promise { diff --git a/web/src/pages/JobDetail.tsx b/web/src/pages/JobDetail.tsx index fd3ad38..ef667e2 100644 --- a/web/src/pages/JobDetail.tsx +++ b/web/src/pages/JobDetail.tsx @@ -80,7 +80,7 @@ export default function JobDetail() { try { const j = await api(`/api/jobs/${id}`); setJob(j); - const pairs = await api('/api/sync-pairs'); + const pairs = (await api('/api/sync-pairs')) ?? []; const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id); setPair(p || null); } catch { @@ -91,9 +91,9 @@ export default function JobDetail() { async function loadLogs(offset: number) { try { - const ls = await api( + const ls = (await api( `/api/jobs/${id}/log?offset=${offset}&limit=1000` - ); + )) ?? []; if (offset === 0) { setLogs(ls); } else { diff --git a/web/src/pages/JobHistory.tsx b/web/src/pages/JobHistory.tsx index b79ef99..9a4e294 100644 --- a/web/src/pages/JobHistory.tsx +++ b/web/src/pages/JobHistory.tsx @@ -60,7 +60,7 @@ export default function JobHistory() { const totalCount = res.headers.get('X-Total-Count'); if (totalCount) setTotal(Number(totalCount)); const data = await res.json(); - setJobs(data); + setJobs(Array.isArray(data) ? data : []); } catch { } finally { setLoading(false);