Skip to content

Comments

feat(admin-ui): add unified operations timeline view#60

Merged
hyp3rd merged 4 commits intomainfrom
feat/admin-ui
Feb 11, 2026
Merged

feat(admin-ui): add unified operations timeline view#60
hyp3rd merged 4 commits intomainfrom
feat/admin-ui

Conversation

@hyp3rd
Copy link
Owner

@hyp3rd 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
Copilot AI review requested due to automatic review settings February 11, 2026 00:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 OperationsTimeline component 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.go files.

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));
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
if (key === "ok" || key === "completed" || key === "success") {
return "success";
}
if (key === "failed" || key === "error" || key === "deadline" || key === "invalid") {
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (key === "failed" || key === "error" || key === "deadline" || key === "invalid") {
if (key === "failed" || key === "error" || key === "deadline" || key === "invalid" || key === "cancelled") {

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +63
sourceFilter: parsed.sourceFilter ?? "all",
statusFilter: parsed.statusFilter ?? "all",
rangeFilter: parsed.rangeFilter ?? "7d",
searchFilter: parsed.searchFilter ?? "",
pageSize,
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +422
let windowMs = 0;
if (rangeFilter === "24h") {
windowMs = 24 * 60 * 60 * 1000;
} else if (rangeFilter === "7d") {
windowMs = 7 * 24 * 60 * 60 * 1000;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
…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
Copilot AI review requested due to automatic review settings February 11, 2026 10:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +29 to +35
type TimelineState = {
sourceFilter: string;
statusFilter: string;
rangeFilter: string;
searchFilter: string;
pageSize: number;
};
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 143 to 189
@@ -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)

Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +25
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),
};
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +437 to +449
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]);
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +65
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 {
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@hyp3rd hyp3rd merged commit 09b3c4a into main Feb 11, 2026
39 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant