Archive milestone artifacts (roadmap, requirements, audit, phase directories) to .planning/milestones/. Evolve PROJECT.md with validated requirements and decision outcomes. Create MILESTONES.md and RETROSPECTIVE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
23 KiB
Phase 4: Frontend - Research
Researched: 2026-02-24 Domain: React + TypeScript frontend integration with admin monitoring APIs Confidence: HIGH
Summary
Phase 4 wires the existing React/TypeScript/Tailwind frontend to the three admin API endpoints delivered in Phase 3: GET /admin/health, GET /admin/analytics, and GET /admin/alerts + POST /admin/alerts/:id/acknowledge. The frontend already has a complete admin-detection pattern, tab-based navigation, an axios-backed adminService.ts, and a ProtectedRoute component. The work is pure frontend integration — no new infrastructure, no new libraries, no new backend routes.
The stack is locked: React 18 + TypeScript + Tailwind CSS + lucide-react icons + react-router-dom v6 + axios (via adminService.ts). The project uses clsx and tailwind-merge (via cn()) for conditional class composition. No charting library is installed. The existing Analytics.tsx component shows the styling and layout patterns to follow. The existing UploadMonitoringDashboard.tsx shows the health indicator pattern with colored circles.
The primary implementation risk is the alert acknowledgement UX: after calling POST /admin/alerts/:id/acknowledge, the local state must update immediately (optimistic update or re-fetch) so the banner disappears without waiting for a full page refresh. The alert banner must render above the tab navigation because it is a global signal, not scoped to a specific tab.
Primary recommendation: Add new components to the existing monitoring tab in App.tsx, extend adminService.ts with the three monitoring API methods, add an AdminMonitoringDashboard component, add an AlertBanner component that renders above the nav inside Dashboard, and add an AdminRoute wrapper that shows access-denied for non-admins who somehow hit the monitoring tab directly.
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| ALRT-03 | Admin sees in-app alert banner for active critical issues; banner disappears after acknowledgement | alertBanner component using GET /admin/alerts + POST /admin/alerts/:id/acknowledge; optimistic state update on acknowledge |
| ANLY-02 (UI) | Admin dashboard shows processing summary: upload counts, success/failure rates, avg processing time | AdminMonitoringDashboard section consuming GET /admin/analytics response shape (AnalyticsSummary interface) |
| HLTH-01 (UI) | Admin dashboard shows health status indicators (green/yellow/red) for all four services with last-checked timestamp | ServiceHealthPanel component consuming GET /admin/health; status → color mapping green=healthy, yellow=degraded, red=down/unknown |
Note: ANLY-02 and HLTH-01 were marked Complete in Phase 3 (backend side). Phase 4 completes their UI delivery. </phase_requirements>
Standard Stack
Core (already installed — no new packages needed)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| react | ^18.2.0 | Component rendering | Project standard |
| typescript | ^5.2.2 | Type safety | Project standard |
| tailwindcss | ^3.3.5 | Utility CSS | Project standard |
| lucide-react | ^0.294.0 | Icons (AlertTriangle, CheckCircle, Activity, Clock, etc.) | Already used throughout |
| axios | ^1.6.2 | HTTP client (via adminService) | Already used in adminService.ts |
| clsx + tailwind-merge | ^2.0.0 / ^2.0.0 | Conditional class composition via cn() |
Already used in Analytics.tsx |
| react-router-dom | ^6.20.1 | Routing, Navigate | Already used |
No New Libraries Required
All required UI capabilities exist in the current stack:
- Status indicators: plain
divwith Tailwind bg-green-500 / bg-yellow-500 / bg-red-500 - Alert banner: fixed/sticky div above nav, standard Tailwind layout
- Timestamps:
new Date(ts).toLocaleString()ortoRelative()— no date library needed - Loading state: existing spinner pattern (
animate-spin rounded-full border-b-2)
Installation: None required.
Architecture Patterns
Recommended File Changes
frontend/src/
├── components/
│ ├── AlertBanner.tsx # NEW — global alert banner above nav
│ ├── AdminMonitoringDashboard.tsx # NEW — health + analytics panel
│ └── (existing files unchanged)
├── services/
│ └── adminService.ts # EXTEND — add getHealth(), getAnalytics(), getAlerts(), acknowledgeAlert()
└── App.tsx # MODIFY — render AlertBanner, wire monitoring tab to new component
Pattern 1: Alert Banner — global, above nav, conditional render
What: A dismissible/acknowledgeable banner rendered inside Dashboard ABOVE the <nav> element, only when alerts.length > 0 and there is at least one status === 'active' alert with a critical alert_type (service_down or service_degraded).
When to use: This pattern matches the existing App.tsx structure — Dashboard is the single top-level component after login, so mounting AlertBanner inside it ensures it is always visible when the admin is on any tab.
Data flow:
Dashboardfetches active alerts on mount viaadminService.getAlerts()- Stores in
activeAlertsstate - Passes
alertsandonAcknowledgecallback toAlertBanner onAcknowledge(id)callsadminService.acknowledgeAlert(id)then updates local state by filtering out the acknowledged alert (optimistic update)
Example:
// In Dashboard component
const [activeAlerts, setActiveAlerts] = useState<AlertEvent[]>([]);
useEffect(() => {
adminService.getAlerts().then(setActiveAlerts).catch(() => {});
}, []);
const handleAcknowledge = async (id: string) => {
await adminService.acknowledgeAlert(id);
setActiveAlerts(prev => prev.filter(a => a.id !== id));
};
// Rendered before <nav>:
{isAdmin && activeAlerts.length > 0 && (
<AlertBanner alerts={activeAlerts} onAcknowledge={handleAcknowledge} />
)}
AlertBanner props:
interface AlertBannerProps {
alerts: AlertEvent[];
onAcknowledge: (id: string) => Promise<void>;
}
Pattern 2: Service Health Panel — status dot + service name + timestamp
What: A 2x2 or 1x4 grid of service health cards. Each card shows: colored dot (green=healthy, yellow=degraded, red=down, gray=unknown), service display name, last-checked timestamp, and latency_ms if available.
Status → color mapping (matches REQUIREMENTS.md "green/yellow/red"):
const statusColor = {
healthy: 'bg-green-500',
degraded: 'bg-yellow-500',
down: 'bg-red-500',
unknown: 'bg-gray-400',
} as const;
Service name display mapping (backend sends snake_case):
const serviceDisplayName: Record<string, string> = {
document_ai: 'Document AI',
llm_api: 'LLM API',
supabase: 'Supabase',
firebase_auth: 'Firebase Auth',
};
Example card:
// Source: admin.ts route response shape
interface ServiceHealthEntry {
service: 'document_ai' | 'llm_api' | 'supabase' | 'firebase_auth';
status: 'healthy' | 'degraded' | 'down' | 'unknown';
checkedAt: string | null;
latencyMs: number | null;
errorMessage: string | null;
}
Pattern 3: Analytics Summary Panel — upload counts + rates + avg time
What: A stat card grid showing totalUploads, succeeded, failed, successRate (as %, formatted), and avgProcessingMs (formatted as seconds or minutes). Matches AnalyticsSummary response from analyticsService.ts.
AnalyticsSummary interface (from backend analyticsService.ts):
interface AnalyticsSummary {
range: string; // e.g. "24h"
totalUploads: number;
succeeded: number;
failed: number;
successRate: number; // 0.0 to 1.0
avgProcessingMs: number | null;
generatedAt: string;
}
Range selector: Include a <select> for 24h, 7d, 30d as query params passed to getAnalytics(range). Matches the pattern in existing Analytics.tsx.
Pattern 4: Admin-only route protection in existing tab system
What: The app uses a tab system inside Dashboard, not separate React Router routes for admin tabs. Admin-only tabs (analytics, monitoring) are already conditionally rendered with isAdmin && (...). The "access-denied" state for non-admin users already exists as inline fallback JSX in App.tsx.
For Phase 4 success criterion 4 (non-admin sees access-denied): The monitoring tab already shows an inline "Access Denied" card for !isAdmin users. The new AdminMonitoringDashboard will render inside the monitoring tab, guarded the same way. No new route is needed.
Pattern 5: adminService.ts extensions
The existing adminService.ts already has an axios client with auto-attached Firebase token. Extend it with typed methods:
// Add to adminService.ts
// Types — import or co-locate
export interface AlertEvent {
id: string;
service_name: string;
alert_type: 'service_down' | 'service_degraded' | 'recovery';
status: 'active' | 'acknowledged' | 'resolved';
message: string | null;
details: Record<string, unknown> | null;
created_at: string;
acknowledged_at: string | null;
resolved_at: string | null;
}
export interface ServiceHealthEntry {
service: string;
status: 'healthy' | 'degraded' | 'down' | 'unknown';
checkedAt: string | null;
latencyMs: number | null;
errorMessage: string | null;
}
export interface AnalyticsSummary {
range: string;
totalUploads: number;
succeeded: number;
failed: number;
successRate: number;
avgProcessingMs: number | null;
generatedAt: string;
}
// Methods to add to AdminService class:
async getHealth(): Promise<ServiceHealthEntry[]> {
const response = await apiClient.get('/admin/health');
return response.data.data;
}
async getAnalytics(range: string = '24h'): Promise<AnalyticsSummary> {
const response = await apiClient.get(`/admin/analytics?range=${range}`);
return response.data.data;
}
async getAlerts(): Promise<AlertEvent[]> {
const response = await apiClient.get('/admin/alerts');
return response.data.data;
}
async acknowledgeAlert(id: string): Promise<AlertEvent> {
const response = await apiClient.post(`/admin/alerts/${id}/acknowledge`);
return response.data.data;
}
Anti-Patterns to Avoid
- Awaiting acknowledgeAlert before updating UI: The banner should disappear immediately on click, not wait for the API round-trip. Use optimistic state update:
setActiveAlerts(prev => prev.filter(a => a.id !== id))before theawait. - Polling alerts on a short interval: Out of scope (WebSockets/SSE out of scope per REQUIREMENTS.md). Fetch alerts once on Dashboard mount. A "Refresh" button on the monitoring panel is acceptable.
- Using console.log: The frontend already uses console.log extensively. New components should match the existing pattern (the backend Winston logger rule does not apply to frontend code — frontend has no logger.ts).
- Building a new ProtectedRoute for admin tabs: The existing tab-visibility pattern (
isAdmin &&) is sufficient. No new routes needed. - Using
anytype: Type the API responses explicitly with interfaces matching backend response shapes.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Colored status indicators | Custom SVG status icon component | Tailwind bg-green-500/bg-yellow-500/bg-red-500 on a w-3 h-3 rounded-full div |
Already established in Analytics.tsx and UploadMonitoringDashboard.tsx |
| Token-attached API calls | Custom fetch with manual header attachment | Extend existing apiClient axios instance in adminService.ts |
Interceptor already handles token attachment automatically |
| Date formatting | Custom date utility | new Date(ts).toLocaleString() inline |
Sufficient; no date library installed |
| Conditional class composition | String concatenation | cn() from ../utils/cn |
Already imported in every component |
Key insight: Every UI pattern needed already exists in the codebase. The implementation is wiring existing patterns to new API endpoints, not building new UI infrastructure.
Common Pitfalls
Pitfall 1: Alert banner position — wrong mount point
What goes wrong: AlertBanner mounted inside a specific tab panel instead of at the top of Dashboard. The banner would only show on one tab.
Why it happens: Placing alert logic where other data-fetching lives (in the monitoring tab content).
How to avoid: Mount AlertBanner in Dashboard return JSX, before the <nav> element. Alert fetching state lives in Dashboard, not in a child component.
Warning signs: Alert banner only visible when "Monitoring" tab is active.
Pitfall 2: Banner not disappearing after acknowledge
What goes wrong: Admin clicks "Acknowledge" on the banner. API call succeeds but banner stays. Admin must refresh the page.
Why it happens: State update waits for API response, or state is not updated at all (only the API called).
How to avoid: Use optimistic state update. Remove the alert from activeAlerts immediately before or during the API call:
const handleAcknowledge = async (id: string) => {
setActiveAlerts(prev => prev.filter(a => a.id !== id)); // optimistic
try {
await adminService.acknowledgeAlert(id);
} catch {
// on failure: re-fetch to restore correct state
adminService.getAlerts().then(setActiveAlerts).catch(() => {});
}
};
Pitfall 3: AdminService.isAdmin hardcoded email
What goes wrong: The existing adminService.ts has private readonly ADMIN_EMAIL = 'jpressnell@bluepointcapital.com' hardcoded. isAdmin(user?.email) works correctly for the current single admin. The backend also enforces admin-email check independently, so this is not a security issue — but it is a code smell.
How to avoid: Do not change this in Phase 4. It is the existing pattern. The admin check is backend-enforced; frontend admin detection is UI-only (for showing/hiding tabs and fetching admin data).
Pitfall 4: Type mismatch between backend AlertEvent and frontend usage
What goes wrong: Frontend defines AlertEvent with fields that differ from backend's AlertEvent interface — particularly status values or field names (checkedAt vs checked_at).
Why it happens: Backend uses snake_case internally; admin route returns camelCase (see admin.ts: checkedAt, latencyMs, errorMessage). Alert model uses snake_case throughout (returned directly from model without remapping).
How to avoid: Check admin.ts response shapes carefully:
GET /admin/healthremaps to camelCase:{ service, status, checkedAt, latencyMs, errorMessage }GET /admin/alertsreturns rawAlertEventmodel data (snake_case):{ id, service_name, alert_type, status, message, created_at, acknowledged_at }GET /admin/analyticsreturnsAnalyticsSummary(camelCase from analyticsService.ts)
Frontend types must match these shapes exactly.
Pitfall 5: Fetching alerts/health when user is not admin
What goes wrong: Dashboard calls adminService.getAlerts() on mount regardless of whether the user is admin. Non-admin users trigger a 404 response (backend returns 404 for non-admin, not 403, per STATE.md decision: "requireAdminEmail returns 404 not 403").
How to avoid: Gate all admin API calls behind isAdmin check:
useEffect(() => {
if (isAdmin) {
adminService.getAlerts().then(setActiveAlerts).catch(() => {});
}
}, [isAdmin]);
Code Examples
AlertBanner Component Structure
// Source: admin.ts route — alerts returned as raw AlertEvent (snake_case)
import { AlertTriangle, X } from 'lucide-react';
import { cn } from '../utils/cn';
interface AlertEvent {
id: string;
service_name: string;
alert_type: 'service_down' | 'service_degraded' | 'recovery';
status: 'active' | 'acknowledged' | 'resolved';
message: string | null;
created_at: string;
acknowledged_at: string | null;
resolved_at: string | null;
details: Record<string, unknown> | null;
}
interface AlertBannerProps {
alerts: AlertEvent[];
onAcknowledge: (id: string) => Promise<void>;
}
const AlertBanner: React.FC<AlertBannerProps> = ({ alerts, onAcknowledge }) => {
const criticalAlerts = alerts.filter(a =>
a.status === 'active' && (a.alert_type === 'service_down' || a.alert_type === 'service_degraded')
);
if (criticalAlerts.length === 0) return null;
return (
<div className="bg-red-600 px-4 py-3">
{criticalAlerts.map(alert => (
<div key={alert.id} className="flex items-center justify-between text-white">
<div className="flex items-center space-x-2">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm font-medium">
{alert.service_name}: {alert.message ?? alert.alert_type}
</span>
</div>
<button
onClick={() => onAcknowledge(alert.id)}
className="flex items-center space-x-1 text-sm underline hover:no-underline ml-4"
>
<X className="h-4 w-4" />
<span>Acknowledge</span>
</button>
</div>
))}
</div>
);
};
ServiceHealthPanel Card
// Source: admin.ts GET /admin/health response (camelCase remapped)
interface ServiceHealthEntry {
service: string;
status: 'healthy' | 'degraded' | 'down' | 'unknown';
checkedAt: string | null;
latencyMs: number | null;
errorMessage: string | null;
}
const statusStyles = {
healthy: { dot: 'bg-green-500', label: 'Healthy', text: 'text-green-700' },
degraded: { dot: 'bg-yellow-500', label: 'Degraded', text: 'text-yellow-700' },
down: { dot: 'bg-red-500', label: 'Down', text: 'text-red-700' },
unknown: { dot: 'bg-gray-400', label: 'Unknown', text: 'text-gray-600' },
} as const;
const serviceDisplayName: Record<string, string> = {
document_ai: 'Document AI',
llm_api: 'LLM API',
supabase: 'Supabase',
firebase_auth: 'Firebase Auth',
};
AdminMonitoringDashboard fetch pattern
// Matches existing Analytics.tsx pattern — useEffect + setLoading + error state
const AdminMonitoringDashboard: React.FC = () => {
const [health, setHealth] = useState<ServiceHealthEntry[]>([]);
const [analytics, setAnalytics] = useState<AnalyticsSummary | null>(null);
const [range, setRange] = useState('24h');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [healthData, analyticsData] = await Promise.all([
adminService.getHealth(),
adminService.getAnalytics(range),
]);
setHealth(healthData);
setAnalytics(analyticsData);
} catch {
setError('Failed to load monitoring data');
} finally {
setLoading(false);
}
}, [range]);
useEffect(() => { loadData(); }, [loadData]);
// ...
};
State of the Art
| Old Approach | Current Approach | Impact |
|---|---|---|
UploadMonitoringDashboard.tsx reads from in-memory upload tracking |
New AdminMonitoringDashboard reads from Supabase via admin API |
Data survives cold starts (HLTH-04 fulfilled) |
Analytics.tsx reads from documentService (old agentic session tables) |
New analytics panel reads from document_processing_events via GET /admin/analytics |
Sourced from the persistent monitoring schema built in Phase 1-3 |
| No alert visibility in UI | AlertBanner above nav, auto-populated from GET /admin/alerts |
ALRT-03 fulfilled |
Deprecated/outdated:
- The existing
Analytics.tsxcomponent (usesdocumentService.getAnalytics()which hits old session/agent tables) — Phase 4 does NOT replace it; it adds a new monitoring section alongside it. The monitoring tab is separate from the analytics tab. UploadMonitoringDashboard.tsx— may be kept or replaced; Phase 4 should use the newAdminMonitoringDashboardin themonitoringtab.
Open Questions
-
Does
UploadMonitoringDashboardget replaced or supplemented?- What we know: The
monitoringtab currently rendersUploadMonitoringDashboard - What's unclear: The Phase 4 requirement says "admin dashboard" — it's ambiguous whether to replace
UploadMonitoringDashboardentirely or addAdminMonitoringDashboardbelow/beside it - Recommendation: Replace the
monitoringtab content withAdminMonitoringDashboard. The oldUploadMonitoringDashboardtracked in-memory state that is now superseded by Supabase-backed data.
- What we know: The
-
Alert polling: once on mount or refresh button?
- What we know: WebSockets/SSE are explicitly out of scope (REQUIREMENTS.md). No polling interval is specified.
- What's unclear: Whether a manual "Refresh" button on the banner is expected
- Recommendation: Fetch alerts once on Dashboard mount (gated by
isAdmin). Include a Refresh button onAdminMonitoringDashboardthat also re-fetches alerts. This matches the existingAnalytics.tsxrefresh pattern.
-
Which alert types trigger the banner?
- What we know: ALRT-03 says "active critical issues". AlertEvent.alert_type is
service_down | service_degraded | recovery. - What's unclear: Does
service_degradedcount as "critical"? - Recommendation: Show banner for both
service_downandservice_degraded(both are actionable alerts that indicate a real problem). Filter outrecoverytype alerts as they are informational.
- What we know: ALRT-03 says "active critical issues". AlertEvent.alert_type is
Validation Architecture
workflow.nyquist_validationis not present in.planning/config.json— the config only hasworkflow.research,workflow.plan_check, andworkflow.verifier. Nonyquist_validationkey. Skipping this section.
Sources
Primary (HIGH confidence)
- Direct codebase inspection —
frontend/src/App.tsx(tab structure, admin detection pattern) - Direct codebase inspection —
frontend/src/services/adminService.ts(axios client, existing methods) - Direct codebase inspection —
frontend/src/components/Analytics.tsx(data-fetch pattern, Tailwind layout) - Direct codebase inspection —
frontend/src/contexts/AuthContext.tsx(useAuth hook, token/user state) - Direct codebase inspection —
backend/src/routes/admin.ts(exact API response shapes) - Direct codebase inspection —
backend/src/models/AlertEventModel.ts(AlertEvent type, field names and casing) - Direct codebase inspection —
backend/src/services/analyticsService.ts(AnalyticsSummary interface) - Direct codebase inspection —
frontend/package.json(installed libraries, confirmed no test framework)
Secondary (MEDIUM confidence)
- Pattern inference from existing components (
UploadMonitoringDashboard.tsxstatus indicator pattern) - React 18 state/effect patterns — verified against direct code inspection in existing components
Tertiary (LOW confidence)
- None
Metadata
Confidence breakdown:
- Standard stack: HIGH — verified directly from package.json and existing component files
- Architecture: HIGH — patterns derived directly from existing App.tsx tab system and adminService.ts structure
- API response shapes: HIGH — read directly from backend admin.ts route and model files
- Pitfalls: HIGH — derived from direct code inspection and STATE.md decisions
Research date: 2026-02-24 Valid until: 2026-03-24 (stable — no moving dependencies; all external packages are pinned)