feat(admin-ui): add unified operations timeline view#60
Conversation
hyp3rd
commented
Feb 11, 2026
- Add OperationsTimeline component combining jobs, schedules and audit activity
- Support live updates via SSE with polling fallback and client-side event merging
- Persist timeline state (filters/search/pagination) in localStorage
- Wire schedule events into the home page and document the timeline behavior
- Minor whitespace cleanup in generated worker proto .pb.go files
- Add OperationsTimeline component combining jobs, schedules and audit activity - Support live updates via SSE with polling fallback and client-side event merging - Persist timeline state (filters/search/pagination) in localStorage - Wire schedule events into the home page and document the timeline behavior - Minor whitespace cleanup in generated worker proto .pb.go files
- Add OperationsTimeline component combining jobs, schedules and audit activity - Support live updates via SSE with polling fallback and client-side event merging - Persist timeline state (filters/search/pagination) in localStorage - Wire schedule events into the home page and document the timeline behavior - Minor whitespace cleanup in generated worker proto .pb.go files
There was a problem hiding this comment.
Pull request overview
Adds a unified OperationsTimeline view to the admin UI overview page, combining job events, schedule events, and audit/queue/DLQ activity into a single live-updating stream with persisted UI state.
Changes:
- Introduces
OperationsTimelinecomponent with SSE live updates + polling fallback, client-side merging, filtering/search, pagination, and localStorage state persistence. - Wires schedule events into the overview page and displays the unified timeline on the home screen.
- Updates documentation (admin-ui README + PRD) and applies minor whitespace cleanup in generated worker protobuf
.pb.gofiles.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/worker/v1/worker.pb.go | Removes stray whitespace line in generated protobuf output. |
| pkg/worker/v1/payload.pb.go | Removes stray whitespace line in generated protobuf output. |
| pkg/worker/v1/admin.pb.go | Removes stray whitespace line in generated protobuf output. |
| admin-ui/src/components/operations-timeline.tsx | New unified timeline UI with SSE/polling, merging, filters, paging, and local persistence. |
| admin-ui/src/app/(app)/page.tsx | Fetches schedule events and renders OperationsTimeline on the overview page. |
| admin-ui/README.md | Documents operations timeline behavior and features. |
| PRD-admin-service.md | Updates PRD to reflect unified timeline implementation and remaining gaps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| searchFilter, | ||
| pageSize, | ||
| }; | ||
| window.localStorage.setItem(timelineStateKey, JSON.stringify(state)); |
There was a problem hiding this comment.
localStorage.setItem can throw (quota exceeded, storage disabled, Safari private mode), which would crash this client component. Wrap the setItem call in a try/catch (similar to recordAuditEvent in src/lib/audit.ts) so the timeline still renders even if state persistence fails.
| window.localStorage.setItem(timelineStateKey, JSON.stringify(state)); | |
| try { | |
| window.localStorage.setItem(timelineStateKey, JSON.stringify(state)); | |
| } catch { | |
| // Ignore storage errors so the timeline still renders even if persistence fails | |
| } |
| if (key === "ok" || key === "completed" || key === "success") { | ||
| return "success"; | ||
| } | ||
| if (key === "failed" || key === "error" || key === "deadline" || key === "invalid") { |
There was a problem hiding this comment.
normalizeStatusBucket doesn't bucket cancelled as failed (it currently falls through to other). Elsewhere in the UI (e.g., JobEvents status filtering) cancelled runs are treated as failures; consider including cancelled in the failed bucket here to keep filtering/labels consistent across pages.
| if (key === "failed" || key === "error" || key === "deadline" || key === "invalid") { | |
| if (key === "failed" || key === "error" || key === "deadline" || key === "invalid" || key === "cancelled") { |
| sourceFilter: parsed.sourceFilter ?? "all", | ||
| statusFilter: parsed.statusFilter ?? "all", | ||
| rangeFilter: parsed.rangeFilter ?? "7d", | ||
| searchFilter: parsed.searchFilter ?? "", | ||
| pageSize, |
There was a problem hiding this comment.
loadTimelineState restores sourceFilter, statusFilter, and rangeFilter from localStorage without validating them against the supported option set. If the stored value is stale/corrupted (e.g., after renaming options), the timeline can end up filtering everything out. Consider whitelisting allowed values ("all" + known sources/statuses/ranges) and falling back to defaults when invalid.
| let windowMs = 0; | ||
| if (rangeFilter === "24h") { | ||
| windowMs = 24 * 60 * 60 * 1000; | ||
| } else if (rangeFilter === "7d") { | ||
| windowMs = 7 * 24 * 60 * 60 * 1000; |
There was a problem hiding this comment.
Range filtering applies whenever rangeFilter !== "all", even if it isn't one of the recognized values. In that case windowMs stays 0, making cutoff === nowMs and filtering out almost all events. Guard the cutoff/filtering with if (windowMs > 0) (or default unknown values to a known range) to avoid accidental empty timelines.
…ection state - Introduce normalized API error payload parsing + helpers for throwing/reading API response errors - Add error code mapping/canonicalization with user-facing hints - Render a shared NoticeBanner across queues/jobs/schedules views to show structured error details - Persist/restore UI section state via localStorage (v1 keys for jobs/queues/schedules) - Update API/route handlers to use consistent apiErrorResponse/apiCodeErrorResponse patterns
- Add REPO_ROOT to Makefile and wire frontend build/lint into the main lint target - Update generated worker/v1 protobuf and gRPC Go files to match updated proto descriptors/imports
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 51 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| type TimelineState = { | ||
| sourceFilter: string; | ||
| statusFilter: string; | ||
| rangeFilter: string; | ||
| searchFilter: string; | ||
| pageSize: number; | ||
| }; |
There was a problem hiding this comment.
PR description says timeline state persists "filters/search/pagination". The persisted TimelineState does not include the current page, so the user’s page selection is lost on reload. If page persistence is intended, include page in TimelineState and in the load/save logic (clamping to the computed pageCount).
| @@ -180,6 +181,12 @@ sec: | |||
| @echo "\nRunning gosec..." | |||
| gosec -exclude-generated -exclude-dir=__examples/* ./... | |||
|
|
|||
| lint-frontend: | |||
| cd $(REPO_ROOT)/admin-ui && npm run lint && cd $(REPO_ROOT) | |||
|
|
|||
| build-frontend: | |||
| cd $(REPO_ROOT)/admin-ui && npm run build && cd $(REPO_ROOT) | |||
|
|
|||
There was a problem hiding this comment.
lint now always runs build-frontend/lint-frontend, but prepare-toolchain doesn't verify node/npm availability or that frontend deps are installed. This will cause confusing failures on environments that previously could run make lint with only Go tooling. Consider adding command checks (and/or an explicit npm ci target) or gating frontend steps behind a flag similar to PROTO_ENABLED.
| const message = (details.message ?? "").trim() || fallbackMessage; | ||
| const code = | ||
| toCanonicalErrorCode(details.code) ?? | ||
| toCanonicalErrorCode(fallbackCode) ?? | ||
| fallbackCode; | ||
|
|
||
| return { | ||
| message, | ||
| code, | ||
| requestId: details.requestId, | ||
| hint: details.hint ?? getErrorHint(code), | ||
| }; |
There was a problem hiding this comment.
normalizeDetails assumes details.message and details.code are strings (calls .trim() / passes to toCanonicalErrorCode). If the server returns a malformed/non-conforming payload (e.g. message: 123), this will throw while handling an error response, masking the original failure. Harden this by type-checking/coercing message/code before trimming/normalizing, and treating non-strings as absent so the fallback values are used.
| useEffect(() => { | ||
| if (typeof window === "undefined") { | ||
| return; | ||
| } | ||
| const state: TimelineState = { | ||
| sourceFilter, | ||
| statusFilter, | ||
| rangeFilter, | ||
| searchFilter, | ||
| pageSize, | ||
| }; | ||
| window.localStorage.setItem(timelineStateKey, JSON.stringify(state)); | ||
| }, [pageSize, rangeFilter, searchFilter, sourceFilter, statusFilter]); |
There was a problem hiding this comment.
localStorage.setItem can throw (quota exceeded / disabled storage, etc.). This effect currently writes without a try/catch, so it can crash the timeline component at runtime. Use persistSectionState (or at least wrap in try/catch) to mirror the error-tolerant behavior used elsewhere.
| const loadTimelineState = (): TimelineState | null => { | ||
| if (typeof window === "undefined") { | ||
| return null; | ||
| } | ||
| try { | ||
| const raw = window.localStorage.getItem(timelineStateKey); | ||
| if (!raw) { | ||
| return null; | ||
| } | ||
| const parsed = JSON.parse(raw) as Partial<TimelineState>; | ||
| const pageSize = timelinePageSizes.includes(parsed.pageSize ?? 0) | ||
| ? (parsed.pageSize as number) | ||
| : timelinePageSizes[0]; | ||
| return { | ||
| sourceFilter: parsed.sourceFilter ?? "all", | ||
| statusFilter: parsed.statusFilter ?? "all", | ||
| rangeFilter: parsed.rangeFilter ?? "7d", | ||
| searchFilter: parsed.searchFilter ?? "", | ||
| pageSize, | ||
| }; | ||
| } catch { |
There was a problem hiding this comment.
loadTimelineState only validates pageSize; sourceFilter / statusFilter / rangeFilter can be any string from localStorage. If they contain an unexpected value, the timeline can appear empty (e.g. unknown rangeFilter yields a 0ms window and filters everything out). Validate these fields against the supported option sets and fall back to defaults when invalid.