feat: container monitoring frontend UI (PR-2)

- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps)
- useEvents() SSE hook with exponential backoff reconnect
- EventProvider context for app-wide SSE stream sharing
- Event-to-toast bridge with severity mapping and deduplication
- Real-time status badge updates replacing 30s polling
- EventSource auth probe (401/429 detection via fetch)
- 14 frontend tests (useEvents + toast-rules)

Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-28 23:43:00 +02:00
parent 4a7f24348c
commit f13a63dc2f
16 changed files with 1979 additions and 1368 deletions
@@ -0,0 +1,125 @@
# PR-2 Apply Report: Frontend UI for Container Monitoring & Notifications
## Status: COMPLETE
All assigned PR-2 tasks have been implemented and validated.
---
## Changed Files
### New Files (9)
| File | Purpose |
|------|---------|
| `apps/web/src/types/events.ts` | TypeScript `InstanceEventPayload` + `InstanceEventMetadata` interfaces |
| `apps/web/src/api/events.ts` | Thin EventSource wrapper + `probeEventStreamStatus` for 401/429 detection |
| `apps/web/src/hooks/use-events.ts` | `useEvents()` hook with SSE connect, exponential backoff reconnect, jitter |
| `apps/web/src/hooks/use-events.test.ts` | Unit tests for useEvents (7 tests) |
| `apps/web/src/components/toast-rules.ts` | Event-to-toast mapping + deduplication logic |
| `apps/web/src/components/toast-rules.test.ts` | Unit tests for toast rules (7 tests) |
| `apps/web/src/state/toast.tsx` | Custom lightweight toast system: ToastContext, ToastProvider, ToastContainer |
| `apps/web/src/state/events.tsx` | EventProvider context that wraps `useEvents()` and exposes events to consumers |
| `apps/web/src/components/event-toast-bridge.tsx` | Bridge component that consumes EventContext and triggers toasts via toast-rules |
### Modified Files (4)
| File | Change |
|------|--------|
| `apps/web/src/components/app-shell.tsx` | Mount `EventProvider` + `ToastProvider` + `EventToastBridge` on all authenticated routes |
| `apps/web/src/components/instance-list.tsx` | Removed 30s health polling; added SSE-driven real-time status updates; retained 60s list refresh |
| `apps/web/src/components/session-card.tsx` | Updated `statusConfig` badge colors: `starting`/`probing` → blue, `unhealthy` → amber |
| `apps/web/src/styles.css` | Added `.status-badge.starting`, `.status-badge.probing`, `.status-badge.unhealthy` + toast animation keyframes |
---
## Implementation Summary
### MON-PR2-001 / MON-PR2-002: useEvents() Hook + events.ts API Client
- `createEventSource()` returns native `EventSource` with `withCredentials: true`
- `useEvents()` hook maintains `events`, `connected`, `reconnectCount`, and `error` state
- Reconnect strategy: `delay = min(30000, 1000 * 2^attempts) * (0.8 + Math.random() * 0.4)`
- On `401` (detected via `probeEventStreamStatus` fetch probe): stops reconnecting and redirects to login
- On `429`: adds 5s penalty before next retry
- Cleans up `EventSource` and pending timeouts on unmount
### MON-PR2-003 / MON-PR2-004: Custom Toast System (No External Dependencies)
- Built a pure React + CSS toast stack:
- `ToastContext` with `addToast` / `removeToast` APIs
- `ToastProvider` manages timer-based auto-dismissal
- `ToastContainer` renders fixed-position stack with inline styles + CSS animation
- Supports severity colors: info (blue), success (green), warning (amber), error (red)
- Auto-dismiss timers: info/success 3s, warning 5s, error 10s (configurable)
- Manual dismiss via × button on each toast
### MON-PR2-005: EventProvider Context
- `EventProvider` mounts at app-shell level, calls `useEvents()` once, shares event stream via React context
- `useEventContext()` allows any descendant to subscribe to the shared SSE stream without creating duplicate connections
### MON-PR2-006: Real-Time Status Badge Updates + Polling Removal
- Removed the 30-second `checkInstanceHealth` polling loop from `instance-list.tsx`
- Added `useEffect` that listens to SSE events and updates `instances` state in-place for matching `instance_id`
- Retained a 60-second `setInterval` for `loadInstances()` as a resilience fallback
- Updated `session-card.tsx` badge color mapping to match spec:
- `starting` / `probing` → blue CSS class
- `unhealthy` → amber CSS class
- Added corresponding CSS rules in `styles.css`
### MON-PR2-007: Integration into App Shell
- `AppShell` now wraps all authenticated routes with `EventProvider` and `ToastProvider`
- `EventToastBridge` is mounted inside the providers to render toasts from SSE events
- Mobile terminal view also gets the providers (toasts still work in terminal)
### MON-PR2-008: Frontend Tests
- `use-events.test.ts`: 7 tests covering event parsing, reconnect backoff, 30s cap, 401 redirect, 429 penalty, unmount cleanup, reconnectCount exposure
- `toast-rules.test.ts`: 7 tests covering event-to-toast mapping (started, running, unhealthy, error) and deduplication within 1s window
---
## Test Commands & Exit Codes
```bash
# Focused new tests
$ cd apps/web && npx vitest run src/hooks/use-events.test.ts src/components/toast-rules.test.ts
# Exit code: 0 (14 passed)
# Broader regression check on modified page/component tests
$ cd apps/web && npx vitest run src/hooks/use-events.test.ts src/components/toast-rules.test.ts src/pages/dashboard.test.tsx src/components/terminal-session-tabs.test.tsx src/components/protected-route.test.tsx
# Exit code: 0 (25 passed)
# TypeScript type check
$ cd apps/web && npx tsc --noEmit
# Exit code: 0 (no errors)
# Lint on new/modified files only
$ cd apps/web && npx eslint <new ts/tsx files> --ext ts,tsx --report-unused-disable-directives --max-warnings 0
# Exit code: 0 (all clean)
```
> **Note:** The full `npx vitest run` shows 4 pre-existing failures in `repositories-settings-tab.test.tsx` (unrelated to this PR). The full `npx eslint` also shows 3 pre-existing errors in `terminal.tsx` and `tool-workshop.tsx`.
---
## Surprises & Decisions
1. **No sonner dependency**: The orchestrator instructed not to install `sonner` because `npm install` hangs in this environment. Implemented a custom ~170-line toast system instead using pure React + inline CSS. It supports severity, auto-dismiss, manual dismiss, and stacking with CSS animations.
2. **EventSource 401/429 detection**: Native `EventSource` does not expose HTTP status codes. Implemented a `probeEventStreamStatus()` helper that does a short `fetch()` to the SSE endpoint with `AbortController` timeout to detect 401/429 before reconnecting.
3. **Badge color CSS classes**: The existing `session-card.tsx` used raw color strings ("green", "yellow", etc.) as CSS class names, but no corresponding CSS classes existed. Added explicit `.status-badge.starting`, `.status-badge.probing`, and `.status-badge.unhealthy` rules to `styles.css`.
4. **Tunnel health removal**: The 30s polling loop in `instance-list.tsx` was the source of `healthStatus` state used for "tunnel error" badges. After removing polling, tunnel-specific health data is no longer available in real time; instances now rely on SSE `status` transitions (e.g., `unhealthy`). The tunnel error badge was removed from `instance-list.tsx` as redundant with the status badge.
5. **App.tsx vs app-shell.tsx**: This codebase has no `App.tsx`; `AppShell` in `app-shell.tsx` is the layout component that wraps all authenticated routes. Providers were mounted there instead.
---
## PR Boundary
This PR includes the complete frontend UI for container monitoring and notifications:
- SSE client hook with reconnect backoff
- Custom toast notification system
- Event provider context
- Real-time status badge updates
- Polling removal from instance list
The next PR (PR-3) should cover:
- Integration tests for lifecycle event flow
- E2E tests for container start → toast and crash detection
- Performance tuning (connection limits, queue bounds, jitter)
- Documentation updates
- Final cleanup and regression validation
@@ -82,11 +82,102 @@ All checks passed!
3. **Delete audit row**: The FK `ON DELETE CASCADE` on `instance_events.instance_id` means the audit row for `instance.deleted` cannot survive deletion. The row is inserted before `session.delete(instance)` and is cascade-deleted on commit. The event bus publication still occurs.
4. **SQLite test compatibility**: Used `JSON` instead of `JSONB` in the SQLAlchemy model to maintain SQLite test compatibility. The migration uses `sa.JSON()` which maps appropriately.
## Remaining Tasks (for PR-2 / PR-3)
## Remaining Tasks (for PR-3)
- Frontend `useEvents()` hook, `ToastProvider`, `toast-rules.ts`
- Frontend badge real-time updates + polling removal
- Integration tests for SSE endpoint (MON-PR1-012)
- Integration tests for lifecycle hooks
- E2E tests
- Performance tuning and documentation
---
# PR-2 Apply Progress: Frontend UI for Container Monitoring & Notifications
## TDD Cycle Evidence
### useEvents Hook (MON-PR2-001 + MON-PR2-002 + MON-PR2-006)
| Cycle | Action | Evidence |
|-------|--------|----------|
| RED | Wrote `use-events.test.ts` with mocks for non-existent `api/events.ts` and `hooks/use-events.ts` | `vitest` collection error: `Cannot find module '../api/events'` |
| GREEN | Implemented `types/events.ts`, `api/events.ts`, and `hooks/use-events.ts` with SSE connect + reconnect backoff | `vitest run src/hooks/use-events.test.ts` → 7 passed |
| REFACTOR | Extracted `probeEventStreamStatus` into `api/events.ts`; added `isMountedRef` guard to prevent state updates after unmount | Tests still pass |
| TRIANGULATE | Added 401 redirect test and 429 penalty test using `probeEventStreamStatus` | Both pass |
### Toast Rules (MON-PR2-003 + MON-PR2-007)
| Cycle | Action | Evidence |
|-------|--------|----------|
| RED | Wrote `toast-rules.test.ts` mocking `../state/toast` | `vitest` collection error: `Cannot find module '../state/toast'` |
| GREEN | Implemented `state/toast.tsx` (custom toast system) and `components/toast-rules.ts` | `vitest run src/components/toast-rules.test.ts` → 7 passed |
| TRIANGULATE | Added deduplication tests (within 1s and after 1s) | Tests pass |
### EventProvider + Integration (MON-PR2-004 + MON-PR2-005 + MON-PR2-007)
| Cycle | Action | Evidence |
|-------|--------|----------|
| RED | Attempted to mount `<ToastProvider>` in `app-shell.tsx` before component existed | Build error: `Cannot find module '../state/toast'` |
| GREEN | Created `state/events.tsx`, `components/event-toast-bridge.tsx`, and integrated all providers into `app-shell.tsx` | `tsc --noEmit` clean; app renders in tests |
## Completed Tasks
- [x] MON-PR2-001: `useEvents()` SSE hook with reconnect backoff (`apps/web/src/hooks/use-events.ts`)
- [x] MON-PR2-002: `events.ts` API client — EventSource wrapper + probe helper (`apps/web/src/api/events.ts`)
- [x] MON-PR2-003: Custom `Toast` system with severity, auto-dismiss, manual dismiss (`apps/web/src/state/toast.tsx`)
- [x] MON-PR2-004: `ToastContainer` that manages toast queue + stacking + CSS animations
- [x] MON-PR2-005: `EventProvider` context — wraps app, provides shared event stream
- [x] MON-PR2-006: Real-time status badge updates — replaced 30s polling in `instance-list.tsx` with SSE-driven updates
- [x] MON-PR2-007: Integrated into `app-shell.tsx` — mounts `ToastProvider` + `EventProvider` + `EventToastBridge`
- [x] MON-PR2-008: Frontend tests for `useEvents` (7 tests) and toast rules (7 tests)
## Files Changed
### New Files
- `apps/web/src/types/events.ts`
- `apps/web/src/api/events.ts`
- `apps/web/src/hooks/use-events.ts`
- `apps/web/src/hooks/use-events.test.ts`
- `apps/web/src/components/toast-rules.ts`
- `apps/web/src/components/toast-rules.test.ts`
- `apps/web/src/state/toast.tsx`
- `apps/web/src/state/events.tsx`
- `apps/web/src/components/event-toast-bridge.tsx`
### Modified Files
- `apps/web/src/components/app-shell.tsx` — mount providers
- `apps/web/src/components/instance-list.tsx` — remove 30s polling, add SSE status updates, add 60s refresh
- `apps/web/src/components/session-card.tsx` — update badge color mapping
- `apps/web/src/styles.css` — add status-badge and toast animation styles
## Test Evidence
```bash
# New frontend unit tests — all pass
$ cd apps/web && npx vitest run src/hooks/use-events.test.ts src/components/toast-rules.test.ts
14 passed
# Regression check on related pages/components
$ cd apps/web && npx vitest run src/hooks/use-events.test.ts src/components/toast-rules.test.ts src/pages/dashboard.test.tsx src/components/terminal-session-tabs.test.tsx src/components/protected-route.test.tsx
25 passed
# TypeScript check
$ cd apps/web && npx tsc --noEmit
# Exit code: 0
# Lint on new/modified files
$ cd apps/web && npx eslint <new ts/tsx files> --ext ts,tsx --report-unused-disable-directives --max-warnings 0
# Exit code: 0
```
## Deviation from Design
1. **No sonner dependency**: The orchestrator explicitly instructed not to install `sonner` because `npm install` hangs in this environment. Implemented a custom ~170-line toast system instead using pure React + inline CSS. It is API-compatible with the expected `toast.info/success/warning/error(message, opts)` contract.
2. **EventSource 401/429 detection**: Native `EventSource.onerror` does not expose HTTP status codes. Added `probeEventStreamStatus()` in `api/events.ts` that performs a short `fetch()` with `AbortController` timeout to detect 401/429 before reconnecting.
3. **App.tsx vs app-shell.tsx**: This codebase has no `App.tsx`; `AppShell` in `app-shell.tsx` is the layout component that wraps all authenticated routes. Providers were mounted there instead.
4. **Tunnel health badge removed from instance-list**: The 30s polling loop was the sole source of tunnel health data. After removing it, the "tunnel error" badge is redundant because SSE status transitions to `unhealthy` are reflected in the status badge itself.
## Remaining Tasks (for PR-3)
- Integration tests for SSE endpoint (MON-PR1-012)
- Integration tests for lifecycle hooks (MON-PR3-001)
- E2E tests for container start → toast and crash detection (MON-PR3-002 / MON-PR3-003)
- Performance tuning — connection limits, queue bounds, jitter (MON-PR3-004)
- Documentation updates (MON-PR3-005)
- Final cleanup and regression validation (MON-PR3-006)