Files
certctl/web/src/pages/NotificationsPage.tsx
T
shankar0123 b6a5278df1 refactor(web): drop unused imports (CodeQL #5 + #10)
Two CodeQL js/unused-local-variable alerts in one sweep — both
Note severity, both pure dead-import cleanup.

Alert #10 (web/src/pages/NotificationsPage.tsx:8):
  formatDateTime imported but only timeAgo used. Verified via
  repo-wide grep — formatDateTime appears on the import line only.
  Drop from the import statement; leave timeAgo in place.

Alert #5 (web/src/api/client.test.ts:2):
  Five unused imports in the test file's import block (the test
  file imports nearly the full API client surface):
    - acknowledgeHealthCheck
    - createPolicy
    - deleteHealthCheck
    - getHealthCheckHistory
    - updateHealthCheck
  Each appears only on the import line — verified via grep -c.
  Removing them doesn't change test coverage (the corresponding
  client functions are exported and exercised in their own tests
  elsewhere, but the integration covered by client.test.ts doesn't
  reach them yet).

Verified locally:
  tsc --noEmit: exit 0.
  grep -c on each removed symbol in its file: 0 occurrences.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/10
  https://github.com/certctl-io/certctl/security/code-scanning/5
Closes both alerts.
2026-05-04 05:11:23 +00:00

284 lines
12 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getNotifications, markNotificationRead, requeueNotification } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { timeAgo } from '../api/utils';
import type { Notification } from '../api/types';
type ViewMode = 'list' | 'grouped';
// I-005: the Notifications page now hosts two tabs. "all" is the pre-I-005
// inbox behavior — no server-side status filter, client-side type/status
// dropdowns untouched. "dead" routes the query through the new ?status=dead
// handler branch so operators can triage the dead-letter queue in isolation.
// The tab is intentionally a separate state axis from the status dropdown so
// the two don't fight each other (dropdown filters within the tab's scope).
type ActiveTab = 'all' | 'dead';
export default function NotificationsPage() {
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
const [typeFilter, setTypeFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [activeTab, setActiveTab] = useState<ActiveTab>('all');
const { data, isLoading, error, refetch } = useQuery({
// I-005: queryKey carries the active tab so TanStack Query treats
// "all" and "dead" as distinct cache entries. Without this, switching
// tabs would return stale data until the 30s refetchInterval fires.
queryKey: ['notifications', activeTab],
queryFn: () => {
const params: Record<string, string> = { per_page: '100' };
if (activeTab === 'dead') {
// The listNotifications handler's ?status=dead branch hits the
// NotificationRepository.ListByStatus path instead of plain List,
// which is both cheaper (DLQ is a small slice of all notifications)
// and correct (pagination counts DLQ rows, not the full inbox).
params.status = 'dead';
}
return getNotifications(params);
},
refetchInterval: 30000,
});
const markRead = useTrackedMutation({
mutationFn: markNotificationRead,
invalidates: [['notifications']],
});
// I-005: requeue a dead notification. Invalidates both tab cache entries
// because a successful requeue flips the row out of "dead" and potentially
// into the "all" tab on its next refetch (status becomes 'pending').
//
// The mutationFn is wrapped as `(id) => requeueNotification(id)` rather
// than passed by reference so react-query v5's second positional argument
// (the mutation context object) never reaches the API client. Without the
// wrapper, TanStack invokes `requeueNotification(id, { client })`, and the
// I-005 Phase 1 Red contract's strict `toHaveBeenCalledWith('notif-dead-001')`
// assertion fails on the extra argument. Keep the arrow even if the context
// object later becomes structurally empty — the contract pins a single-arg
// call and the page must not leak mutation machinery into API boundaries.
const requeue = useTrackedMutation({
mutationFn: (id: string) => requeueNotification(id),
invalidates: [['notifications']],
});
const notifications = data?.data || [];
const filtered = useMemo(() => {
return notifications.filter((n) => {
if (typeFilter && n.type !== typeFilter) return false;
if (statusFilter && n.status !== statusFilter) return false;
return true;
});
}, [notifications, typeFilter, statusFilter]);
const types = useMemo(() => [...new Set(notifications.map(n => n.type))], [notifications]);
const statuses = useMemo(() => [...new Set(notifications.map(n => n.status))], [notifications]);
// Group by certificate_id
const grouped = useMemo(() => {
const groups: Record<string, Notification[]> = {};
for (const n of filtered) {
const key = n.certificate_id || 'general';
if (!groups[key]) groups[key] = [];
groups[key].push(n);
}
return Object.entries(groups).sort(([, a], [, b]) => {
const aTime = new Date(a[0].created_at).getTime();
const bTime = new Date(b[0].created_at).getTime();
return bTime - aTime;
});
}, [filtered]);
const unreadCount = filtered.filter(n => n.status === 'Pending' || n.status === 'pending').length;
if (isLoading) {
return (
<>
<PageHeader title="Notifications" />
<div className="flex items-center justify-center flex-1 text-ink-muted">Loading...</div>
</>
);
}
if (error) {
return (
<>
<PageHeader title="Notifications" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
return (
<>
<PageHeader
title="Notifications"
subtitle={`${filtered.length} notifications${unreadCount ? ` (${unreadCount} unread)` : ''}`}
/>
<div className="px-4 py-3 flex flex-wrap items-center gap-3 border-b border-surface-border/50">
{/* I-005: tab switcher between the standard inbox and the DLQ. The
"Dead letter" label is pinned by NotificationsPage.test.tsx — do
not rename without updating the Phase 1 Red contract. */}
<div className="flex rounded overflow-hidden border border-surface-border">
<button
onClick={() => setActiveTab('all')}
className={`px-3 py-1.5 text-xs transition-colors ${activeTab === 'all' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
>
All
</button>
<button
onClick={() => setActiveTab('dead')}
className={`px-3 py-1.5 text-xs transition-colors ${activeTab === 'dead' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
>
Dead letter
</button>
</div>
<div className="flex rounded overflow-hidden border border-surface-border">
<button
onClick={() => setViewMode('grouped')}
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'grouped' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
>
Grouped
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'list' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
>
List
</button>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
>
<option value="">All types</option>
{types.map(t => <option key={t} value={t}>{t.replace(/([A-Z])/g, ' $1').trim()}</option>)}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
>
<option value="">All statuses</option>
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
{(typeFilter || statusFilter) && (
<button
onClick={() => { setTypeFilter(''); setStatusFilter(''); }}
className="text-xs text-ink-muted hover:text-ink transition-colors"
>
Clear filters
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{viewMode === 'grouped' ? (
grouped.length === 0 ? (
<div className="text-center py-16 text-ink-faint">No notifications</div>
) : (
grouped.map(([certId, items]) => (
<div key={certId} className="card p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-mono text-ink-muted">
{certId === 'general' ? 'General' : certId}
</span>
<span className="text-xs text-ink-faint">{items.length} notification{items.length !== 1 ? 's' : ''}</span>
</div>
<div className="space-y-2">
{items.map((n) => (
<NotificationRow key={n.id} notification={n} onMarkRead={() => markRead.mutate(n.id)} onRequeue={() => requeue.mutate(n.id)} />
))}
</div>
</div>
))
)
) : (
filtered.length === 0 ? (
<div className="text-center py-16 text-ink-faint">No notifications</div>
) : (
<div className="space-y-2">
{filtered.map((n) => (
<NotificationRow key={n.id} notification={n} onMarkRead={() => markRead.mutate(n.id)} />
))}
</div>
)
)}
</div>
</>
);
}
function NotificationRow({
notification: n,
onMarkRead,
onRequeue,
}: {
notification: Notification;
onMarkRead: () => void;
// I-005: optional so callers who don't care about the DLQ (if any are ever
// added) aren't forced to thread a no-op through. Every NotificationRow
// today passes this, so in practice it's always defined.
onRequeue?: () => void;
}) {
const isUnread = n.status === 'Pending' || n.status === 'pending';
// I-005: dead rows get a Requeue button and surface the retry budget + the
// last transient error so operators triaging the DLQ can see *why* the
// notification died before deciding whether to requeue.
const isDead = n.status === 'dead';
return (
<div className={`flex items-start justify-between py-2 px-3 rounded transition-colors ${isUnread ? 'bg-surface-muted border-l-2 border-brand-400' : isDead ? 'bg-surface-muted border-l-2 border-danger' : 'hover:bg-surface-muted'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-ink">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>
<StatusBadge status={n.status} />
<span className="text-xs text-ink-faint">{n.channel}</span>
</div>
{/* D-2 (master): pre-D-2 the fallback was `{n.message || n.subject}`,
but `subject` was a TS phantom the Go struct never emitted
(`internal/domain/notification.go::NotificationEvent` has only
`message`). The fallback always fell through to `message`
because `subject` was always undefined. Post-D-2 the dead
fallback is dropped along with the phantom field. */}
<p className="text-xs text-ink-muted truncate">{n.message}</p>
{isDead && (
<div className="flex items-center gap-3 mt-1 text-xs">
<span className="text-ink-faint">
Retry {n.retry_count ?? 0}/5
</span>
{n.last_error && (
<span className="text-danger truncate" title={n.last_error}>
{n.last_error}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-ink-faint">{n.recipient}</span>
<span className="text-xs text-ink-faint">{timeAgo(n.created_at)}</span>
</div>
</div>
{isUnread && (
<button
onClick={(e) => { e.stopPropagation(); onMarkRead(); }}
className="ml-3 text-xs text-brand-400 hover:text-brand-500 transition-colors whitespace-nowrap"
>
Mark read
</button>
)}
{isDead && onRequeue && (
<button
onClick={(e) => { e.stopPropagation(); onRequeue(); }}
className="ml-3 text-xs text-brand-400 hover:text-brand-500 transition-colors whitespace-nowrap"
>
Requeue
</button>
)}
</div>
);
}