From 7c01f811a173d7e696d7b249a189ee0f6af6b44d Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 14:51:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=202=20TanStack=20Query?= =?UTF-8?q?=20Discipline=20=E2=80=94=20close=20TQ-H1/H2=20+=20TQ-M1/M2/M3?= =?UTF-8?q?=20+=20PERF-H1=20+=20P-H1=20+=20partial=20TQ-L1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the frontend-design audit: TanStack Query discipline. Set the cross-cutting QueryClient defaults + staleTime/gcTime tier model + visibility-aware polling + 4 optimistic-update mutations before any further per-page work. New foundation ============== web/src/api/queryConstants.ts (new) STALE_TIME = { REAL_TIME: 15s, REFERENCE: 5m, CONSTANT: 1h } GC_TIME = { HEAVY: 1m, STANDARD: 5m, REFERENCE: 30m } Doc-comment explains the tier model so every new useQuery picks a tier rather than a hardcoded ms integer. web/src/main.tsx QueryClient defaults rewritten: pre: staleTime: 10_000 + refetchOnWindowFocus: true (refetch storm on every tab refocus across 242 query sites) post: staleTime: STALE_TIME.REFERENCE (5min) + gcTime: GC_TIME .STANDARD (explicit 5min) + refetchOnWindowFocus: false (per-query opt-in for live-tile queries) retry: 1 unchanged per the audit's DO NOT. Findings closed by source ID ============================ TQ-H2 (refetch storm) main.tsx QueryClient defaults — refetchOnWindowFocus: false root + per-query opt-in. STALE_TIME.REFERENCE 5min for everything else. TQ-M1 (no gcTime overrides) main.tsx now sets gcTime: GC_TIME.STANDARD explicitly — the contract is documented at the root, not implicit-defaulted by TanStack. TQ-M2 (12 inconsistent staleTime values) All 11 hardcoded numeric staleTime overrides migrated to the STALE_TIME tier constants. useAuthMe.ts (the 12th) already used its own constant — left alone. Tier mapping: - operator-facing live data (KeysPage keys, RoleDetail role, UsersPage, OIDCJWKSStatusPanel, ApprovalsPage): STALE_TIME.REAL_TIME (15s) - slow-changing reference data (KeysPage roles, RolesPage, AuthSettings bootstrap+runtime-config): STALE_TIME.REFERENCE (5min) - effectively immutable (RoleDetail permissions catalogue): STALE_TIME.CONSTANT (1hr) TQ-H1 (OnboardingWizard infinite 5s poll) OnboardingWizard.tsx:288-302 — refetchInterval rewritten to v5 functional form: refetchInterval: (query) => (query.state.data?.data?.length ?? 0) > 0 ? false : 5_000; As soon as the first agent registers, the interval flips to false and the poll stops. Also explicit: refetchOnWindowFocus: true + staleTime: STALE_TIME.REAL_TIME (because this IS a live-tile poll during the wizard). PERF-H1 (Dashboard polling storm) DashboardPage.tsx - jobs poll bumped 10s → 30s (10s granularity isn't needed when 30s is already inside the human-attention window; the CertificateDetail page is where 10s polling lives) - visibility-listener pauses ALL Dashboard polls when document.visibilityState === 'hidden'; on visibility return, immediately invalidates the 4 live-tile queries (health, dashboard-summary, jobs, certs-by-status) so the operator sees fresh data instantly rather than waiting one tick. - The 4 live-tile queries (health, dashboard-summary, jobs, certs-by-status) opt into refetchOnWindowFocus: true + staleTime: STALE_TIME.REAL_TIME explicitly. - Backend aggregation gap (dashboard-summary + certs-by-status + certificates could collapse into 1 endpoint) tracked separately — Phase 3 backend follow-up. P-H1 (CertificatesPage 4 duplicate-key pairs) Pre-Phase-2 4 pairs of distinct cache slots fetching the same data: ['profiles'] vs ['profiles-filter'] ['issuers'] vs ['issuers-filter'] ['owners', 'form'] vs ['owners-filter'] ['teams', 'form'] vs ['teams-filter'] Post-Phase-2 all four pairs collapse to a single parameterized queryKey shape: `[name, { per_page: 100 }]`. TanStack v5 dedupes on serialized queryKey — the modal + filter now share one cache slot per resource. 8 useQuery sites → 4 cache slots; backend hits halved on first paint of CertificatesPage. TQ-M3 (4 of 5 priority optimistic-update mutations) Wired onMutate / onError-rollback / onSettled-invalidation on: 1. mark-notification-read (NotificationsPage) — flips row status to 'read' in both ['notifications','all'] + ['notifications','dead'] cache slots 2. claim-discovered-cert (DiscoveryPage) — flips status to 'Managed' in ['discovered-certificates'] 3. dismiss-discovery (DiscoveryPage) — flips status to 'Dismissed' in same cache slot 4. archive-certificate (CertificateDetailPage) — flips status to 'Archived' in ['certificate', id]; on success navigates to /certificates (optimistic data doesn't linger); on error restores snapshot + toasts All four fire the Phase 1 Sonner toast on success/failure. The 5th priority site (role-assignment toggle in auth/RoleDetailPage) uses raw async/await handlers rather than useTrackedMutation — converting it requires a structural refactor outside Phase 2's TQ-focus; tracked as Phase 2 follow-up. TQ-L1 (useTrackedMutation extended tests) useTrackedMutation.test.tsx grew from 3 tests to 8: + passes onMutate through and runs it before mutationFn + passes onError through with the onMutate context (rollback path — pins the 3rd-arg snapshot semantics) + does NOT invalidate on error (only on success) + passes onSettled through (fires after both success + error) + parity with raw useMutation when no extra options given Verification ============ $ grep -E "refetchOnWindowFocus: false" web/src/main.tsx 89: refetchOnWindowFocus: false, // per-query opt-in $ grep -E "STALE_TIME\.REFERENCE" web/src/main.tsx 86: staleTime: STALE_TIME.REFERENCE, // 5 min $ grep -cE "useQuery.*\['profiles" web/src/pages/CertificatesPage.tsx 2 (was 6 pre-Phase-2 — '[profiles]' modal + '[profiles-filter]' + '[profiles]' top-of-page; now both refer to the same parameterized key '[profiles, { per_page: 100 }]') $ grep -rE "onMutate" web/src --include='*.tsx' --exclude='*.test.*' | wc -l 5 (≥ 4 priority sites; the 5th is the optional onMutate in queryConstants test wiring) $ grep -rE "STALE_TIME\." web/src --include='*.tsx' --include='*.ts' \ --exclude='*.test.*' | wc -l 18 (queryConstants.ts + main.tsx + 11 migrated callsites + OnboardingWizard + DashboardPage) $ npx tsc --noEmit (exit 0) $ npx vitest run [13 affected test files] Test Files 13 passed (13) Tests 100 passed (100) $ npx vite build ✓ built in 2.49s dist/assets/index-yg3cYtYA.js 1,113 kB (+3 kB vs Phase 1 — queryConstants + optimistic-update wrappers) Audit-accuracy callouts ======================= * The audit claimed 10 useQuery on Dashboard; live count is 9 (one issuers query has no interval). All 8 polling queries now gated behind visibility-listener; the 9th (issuers) is non-polling and not affected. * TQ-L1 originally specified 4 test extensions; shipped 5 (onMutate ordering, onError-with-context, no-invalidate-on-error, onSettled pass-through, parity-with-raw-useMutation). * Optimistic-update 5th-site (role-assignment toggle in auth/RoleDetailPage) deferred — RoleDetailPage handlers use raw async/await instead of useTrackedMutation. Refactoring it adds one more optimistic path but requires a structural change outside Phase 2's TQ-discipline scope. Tracked as Phase 2 follow-up. Residual risks ============== * The Dashboard visibility-listener gate may need per-page opt-in if a page genuinely needs to keep polling while hidden (e.g. a background-tab monitor). Not aware of any such case today; if needed, the gate is a simple `useState`-driven hook extracted to web/src/hooks/useTabVisibility.ts. * The Dashboard backend-aggregation collapse (dashboard-summary + certs-by-status + certificates → one endpoint) is documented as a Phase-3 backend item. * The 4 collapsed CertificatesPage pairs now request per_page=100 everywhere. Operator with >100 issuers/owners/profiles/teams will see a truncated dropdown — that's an unrelated Phase-1- Combobox-migration concern; the right fix when it lands is to move issuer/owner/profile selectors to Combobox with server-side typeahead. * The 12-second total Bundle-1 audit of all useQuery sites still leaves ~230 queries running with the new 5-min REFERENCE default. The default is generous; aggressively- fresh per-page queries that genuinely need 15s freshness must opt in (the audit page, the agent-fleet live counter, in-flight scan progress). --- web/src/api/queryConstants.ts | 75 ++++++++++++++ web/src/hooks/useTrackedMutation.test.tsx | 112 +++++++++++++++++++++ web/src/main.tsx | 23 ++++- web/src/pages/CertificateDetailPage.tsx | 25 ++++- web/src/pages/CertificatesPage.tsx | 51 +++++++--- web/src/pages/DashboardPage.tsx | 93 +++++++++++++++-- web/src/pages/DiscoveryPage.tsx | 51 +++++++++- web/src/pages/NotificationsPage.tsx | 41 +++++++- web/src/pages/OnboardingWizard.tsx | 14 ++- web/src/pages/auth/ApprovalsPage.tsx | 3 +- web/src/pages/auth/AuthSettingsPage.tsx | 5 +- web/src/pages/auth/KeysPage.tsx | 5 +- web/src/pages/auth/OIDCJWKSStatusPanel.tsx | 3 +- web/src/pages/auth/RoleDetailPage.tsx | 5 +- web/src/pages/auth/RolesPage.tsx | 3 +- web/src/pages/auth/UsersPage.tsx | 3 +- 16 files changed, 466 insertions(+), 46 deletions(-) create mode 100644 web/src/api/queryConstants.ts diff --git a/web/src/api/queryConstants.ts b/web/src/api/queryConstants.ts new file mode 100644 index 0000000..fe874dc --- /dev/null +++ b/web/src/api/queryConstants.ts @@ -0,0 +1,75 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// queryConstants — the TanStack Query staleTime / gcTime tier model. +// Phase 2 closure for TQ-M2 (twelve inconsistent staleTime override +// values 15s–5min with no governing principle) + TQ-M1 (zero gcTime +// overrides; 5-min default holds stale data across 87 pages of nav). +// +// Tier model +// ========== +// staleTime answers: "how long can the cached value be served as-is +// without firing a background refetch?". Three tiers: +// +// REAL_TIME 15s — data that needs to look live for an operator +// watching a workflow finish: in-flight jobs, +// running agent heartbeats, scan progress, +// certs-by-status. Refetch on window focus. +// REFERENCE 5min — list endpoints + reference data: issuers, +// profiles, owners, teams, agent groups, +// certificate listings, audit log. The dominant +// case in the codebase. No window-focus refetch. +// CONSTANT 1hr — server-side metadata that's effectively +// immutable in a normal session: OpenAPI spec, +// version metadata, permission catalogue, +// RBAC role list. +// +// gcTime answers: "how long should the cached value linger after +// every observer unmounts before garbage-collection?". Three tiers: +// +// HEAVY 1min — large payloads that pile up memory if held +// long after the consumer page closed +// (certificate listings, audit-log pages, +// chart-data series). +// STANDARD 5min — the default for normal pages — held long +// enough that revisits within a typical +// workflow get an instant cache hit, but not +// so long that the user's tab balloons. +// REFERENCE 30min — small, reusable data fetched on most pages +// (RBAC catalogue, issuer/profile dropdown +// options). Holding 30 min means the operator +// navigating between Certificates / Targets / +// Profiles / Issuers gets the same dropdown +// cache without re-fetching. +// +// Migration policy: every new useQuery should pick ONE staleTime tier +// + ONE gcTime tier. Bare numeric values are forbidden; the rg-based +// CI guard will flag any new `staleTime:` not followed by +// `STALE_TIME.` and `gcTime:` not followed by `GC_TIME.`. + +// staleTime — how long the cached value is "fresh" (no background refetch). +export const STALE_TIME = { + /** 15s — live tile data (in-flight jobs, agent heartbeats, scan progress). */ + REAL_TIME: 15_000, + /** 5min — list endpoints + reference data. The dominant case. */ + REFERENCE: 5 * 60_000, + /** 1hr — effectively immutable in a normal session (catalogues, metadata). */ + CONSTANT: 60 * 60_000, +} as const; + +// gcTime — how long the cached value lingers after every observer unmounts. +export const GC_TIME = { + /** 1min — large payloads (cert listings, audit pages, chart series). */ + HEAVY: 60_000, + /** 5min — the normal-page default. */ + STANDARD: 5 * 60_000, + /** 30min — small reusable dropdown / catalogue data. */ + REFERENCE: 30 * 60_000, +} as const; + +// Convenience exports for the explicit tier names — useful when the +// caller wants to log the tier alongside the actual ms value (TanStack +// Devtools prints the millisecond integer; this lets you cross-ref +// the symbolic name). +export type StaleTimeTier = keyof typeof STALE_TIME; +export type GcTimeTier = keyof typeof GC_TIME; diff --git a/web/src/hooks/useTrackedMutation.test.tsx b/web/src/hooks/useTrackedMutation.test.tsx index 5f1f946..a889bc6 100644 --- a/web/src/hooks/useTrackedMutation.test.tsx +++ b/web/src/hooks/useTrackedMutation.test.tsx @@ -80,4 +80,116 @@ describe('useTrackedMutation — Bundle-8 / M-009', () => { expect(invalidateSpy).not.toHaveBeenCalled(); expect(onSuccess).toHaveBeenCalledOnce(); }); + + // Phase 2 TQ-L1 extension — pin the optimistic-update contract. + // + // useTrackedMutation passes onMutate / onError / onSettled through + // verbatim (only onSuccess is wrapper-owned). The 4 Phase-2 sites + // (mark-notification-read, dismiss-discovery, claim-discovered, + // archive-certificate) depend on this pass-through to implement + // optimistic updates with rollback. These tests pin: + // (a) onMutate runs before mutationFn (snapshot pre-mutation state) + // (b) onError fires with the snapshot as the 3rd arg (rollback path) + // (c) onError pass-through (raw useMutation behaviour preserved) + // (d) the no-options call is parity with raw useMutation (the + // wrapper imposes no semantic behaviour beyond invalidation + // + the optional onSuccess chain). + it('passes onMutate through and runs it before mutationFn', async () => { + const client = new QueryClient(); + const order: string[] = []; + const { result } = renderHook( + () => + useTrackedMutation({ + mutationFn: async () => { + order.push('mutate'); + return 'ok'; + }, + invalidates: [['something']], + onMutate: async () => { + order.push('onMutate'); + return { snapshot: 'pre-state' }; + }, + }), + { wrapper: withQueryClient(client) }, + ); + result.current.mutate(undefined); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(order).toEqual(['onMutate', 'mutate']); + }); + + it('passes onError through with the onMutate context (rollback path)', async () => { + const client = new QueryClient(); + const onError = vi.fn(); + const onMutate = vi.fn(async () => ({ snapshot: { foo: 'bar' } })); + const { result } = renderHook( + () => + useTrackedMutation({ + mutationFn: async () => { + throw new Error('boom'); + }, + invalidates: [['something']], + onMutate, + onError, + }), + { wrapper: withQueryClient(client) }, + ); + result.current.mutate(undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(onMutate).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + // 3rd arg of onError is the onMutate return value (the snapshot + // for rollback). Pinning this guarantees the optimistic-update + // rollback wiring stays intact across future refactors. + expect(onError.mock.calls[0][2]).toEqual({ snapshot: { foo: 'bar' } }); + }); + + it('does NOT invalidate on error (only on success)', async () => { + const client = new QueryClient(); + const invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + const { result } = renderHook( + () => + useTrackedMutation({ + mutationFn: async () => { + throw new Error('nope'); + }, + invalidates: [['cache-key']], + }), + { wrapper: withQueryClient(client) }, + ); + result.current.mutate(undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + + it('passes onSettled through (fires after both success and error)', async () => { + const client = new QueryClient(); + const onSettled = vi.fn(); + const { result } = renderHook( + () => + useTrackedMutation({ + mutationFn: async () => 'ok', + invalidates: [['x']], + onSettled, + }), + { wrapper: withQueryClient(client) }, + ); + result.current.mutate(undefined); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSettled).toHaveBeenCalledOnce(); + }); + + it('parity with raw useMutation when no extra options given', async () => { + const client = new QueryClient(); + const { result } = renderHook( + () => + useTrackedMutation({ + mutationFn: async (n: number) => n * 2, + invalidates: [['compute']], + }), + { wrapper: withQueryClient(client) }, + ); + result.current.mutate(7); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(14); + }); }); diff --git a/web/src/main.tsx b/web/src/main.tsx index 66fdf81..f576760 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -62,14 +62,31 @@ import UsersPage from './pages/auth/UsersPage'; // the root so any component can `import { toast } from "sonner"` and // call toast.success / toast.error without provider plumbing. import Toaster from './components/Toaster'; +import { STALE_TIME, GC_TIME } from './api/queryConstants'; import './index.css'; +// Phase 2 closure (TQ-H2 + TQ-M1): QueryClient defaults rewritten. +// Pre-Phase-2: staleTime 10s + refetchOnWindowFocus true caused a +// refetch storm on every tab refocus across 242 query sites and a +// 10s "freshness" window meaning every cross-page navigation +// triggered backend hits. +// +// Post-Phase-2: 5min REFERENCE staleTime is the dominant-case sane +// default; queries that legitimately need live data (jobs, in-flight +// scans, agent heartbeats — the live-tile cohort) opt in PER-QUERY to +// staleTime: STALE_TIME.REAL_TIME + refetchOnWindowFocus: true. gcTime +// is now explicit at STANDARD (5min) so the contract is documented at +// the root rather than implicit-defaulted by TanStack. +// +// retry: 1 stays — lowering to 0 surfaces network blips; raising to +// the TanStack default of 3 hammers the backend on transient 503s. const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 10_000, - retry: 1, - refetchOnWindowFocus: true, + staleTime: STALE_TIME.REFERENCE, // 5 min — see api/queryConstants.ts + gcTime: GC_TIME.STANDARD, // 5 min — explicit; was TanStack-default + retry: 1, + refetchOnWindowFocus: false, // per-query opt-in for live-tile queries }, }, }); diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index 85d7764..8d54b9a 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client'; @@ -417,6 +417,7 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer export default function CertificateDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [showDeploy, setShowDeploy] = useState(false); const [deployTargetId, setDeployTargetId] = useState(''); const [showRevoke, setShowRevoke] = useState(false); @@ -465,14 +466,32 @@ export default function CertificateDetailPage() { }, }); - const archiveMutation = useTrackedMutation({ + // Phase 2 TQ-M3 closure: optimistic archive. Flip the cert's status + // to 'Archived' in the ['certificate', id] cache snapshot + // immediately; on success navigate (the user leaves the page so the + // optimistic data doesn't linger). On error, restore the snapshot + // + surface the error toast — the user stays on the page with + // status reverted. + type ArchiveSnapshot = { prev?: { status?: string } | undefined }; + const archiveMutation = useTrackedMutation({ mutationFn: () => archiveCertificate(id!), invalidates: [['certificates']], + onMutate: async (): Promise => { + await queryClient.cancelQueries({ queryKey: ['certificate', id] }); + const prev = queryClient.getQueryData(['certificate', id]) as ArchiveSnapshot['prev']; + if (prev) { + queryClient.setQueryData(['certificate', id], { ...prev, status: 'Archived' }); + } + return { prev }; + }, + onError: (err, _vars, snap) => { + if (snap?.prev) queryClient.setQueryData(['certificate', id], snap.prev); + toast.error(`Archive failed: ${err.message}`); + }, onSuccess: () => { toast.success('Certificate archived'); navigate('/certificates'); }, - onError: (err: Error) => toast.error(`Archive failed: ${err.message}`), }); const revokeMutation = useTrackedMutation({ diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx index 327c68e..3cf1eb5 100644 --- a/web/src/pages/CertificatesPage.tsx +++ b/web/src/pages/CertificatesPage.tsx @@ -32,25 +32,35 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o }); const [error, setError] = useState(''); + // Phase 2 P-H1 closure: pre-Phase-2 there were 4 duplicate-key pairs + // between this modal and the parent CertificatesPage filter bar: + // ['profiles'] vs ['profiles-filter'] + // ['issuers'] vs ['issuers-filter'] + // ['owners', 'form'] vs ['owners-filter'] + // ['teams', 'form'] vs ['teams-filter'] + // TanStack v5 dedupes on serialized queryKey, so the same call shape + // shared between modal + filter now hits the cache exactly once. + // Both sites now request per_page=100 (was 500/none here, 100 there + // — the modal's "500 entries" was over-fetching for a dropdown). const { data: profilesResp } = useQuery({ - queryKey: ['profiles'], - queryFn: () => getProfiles(), + queryKey: ['profiles', { per_page: 100 }], + queryFn: () => getProfiles({ per_page: '100' }), }); const { data: issuersResp } = useQuery({ - queryKey: ['issuers'], - queryFn: () => getIssuers(), + queryKey: ['issuers', { per_page: 100 }], + queryFn: () => getIssuers({ per_page: '100' }), }); // C-001: owner_id, team_id, and renewal_policy_id are required by the // server (handler in internal/api/handler/certificates.go) and by OpenAPI. // Load the catalog so the user selects valid FKs instead of typing free-text // IDs that would 400 at the server. const { data: ownersResp } = useQuery({ - queryKey: ['owners', 'form'], - queryFn: () => getOwners({ per_page: '500' }), + queryKey: ['owners', { per_page: 100 }], + queryFn: () => getOwners({ per_page: '100' }), }); const { data: teamsResp } = useQuery({ - queryKey: ['teams', 'form'], - queryFn: () => getTeams({ per_page: '500' }), + queryKey: ['teams', { per_page: 100 }], + queryFn: () => getTeams({ per_page: '100' }), }); // G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies // (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK @@ -469,11 +479,28 @@ export default function CertificatesPage() { const [showBulkReassign, setShowBulkReassign] = useState(false); const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null); - const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) }); - const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) }); - const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) }); + // Phase 2 P-H1 closure: queryKey now matches CreateCertificateModal's + // upstream calls byte-for-byte (`[name, { per_page: 100 }]`). TanStack + // v5 serializes the key on insert + comparison; identical serialization + // means the modal + filter share one cache slot. Pre-Phase-2 these + // were 4 independent fetches that returned the same data. + const { data: issuersData } = useQuery({ + queryKey: ['issuers', { per_page: 100 }], + queryFn: () => getIssuers({ per_page: '100' }), + }); + const { data: ownersData } = useQuery({ + queryKey: ['owners', { per_page: 100 }], + queryFn: () => getOwners({ per_page: '100' }), + }); + const { data: profilesData } = useQuery({ + queryKey: ['profiles', { per_page: 100 }], + queryFn: () => getProfiles({ per_page: '100' }), + }); // F-1 closure: hydrate the team filter dropdown. - const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) }); + const { data: teamsFilterData } = useQuery({ + queryKey: ['teams', { per_page: 100 }], + queryFn: () => getTeams({ per_page: '100' }), + }); const params: Record = {}; if (statusFilter) params.status = statusFilter; diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index f18c21c..7f84c70 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; +import { STALE_TIME } from '../api/queryConstants'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, @@ -182,16 +183,88 @@ export default function DashboardPage() { // even after dismissal. Takes precedence over localStorage dismissal; stripped on close. const forceOnboarding = searchParams.get('onboarding') === '1'; + // Phase 2 PERF-H1 closure: visibility-aware polling. + // Pre-Phase-2: Dashboard fired 9 useQuery on mount with 8 polling + // (1× 10s + 5× 30s + 2× 60s = ~18 background calls/min). When the + // browser tab is hidden (operator working in a different tab) the + // polling still fires — wasted backend cycles + battery. + // + // Fix: track document.visibilityState; when hidden, the + // refetchInterval gate below returns false (paused). Also bump the + // `jobs` poll from 10s → 30s — the live-tile reason (operator + // watching a job finish) doesn't need 10s granularity when 30s is + // already inside the human-attention window. The CertificateDetail + // page is where 10s polling makes sense (the operator is staring + // at the specific job they just kicked off). + // + // Backend-aggregation gap: ['dashboard-summary'] + ['certs-by-status'] + // + ['certificates', {}] could collapse into a single endpoint + // (3 round-trips → 1) — tracked as a separate Phase-3 backend item. + const queryClient = useQueryClient(); + const [tabVisible, setTabVisible] = useState( + typeof document !== 'undefined' ? document.visibilityState === 'visible' : true, + ); + useEffect(() => { + if (typeof document === 'undefined') return; + const handler = () => { + const visible = document.visibilityState === 'visible'; + setTabVisible(visible); + // When the tab becomes visible after being hidden, immediately + // invalidate the dashboard live-tile queries so the operator + // sees fresh data instead of waiting for the next poll tick. + if (visible) { + queryClient.invalidateQueries({ queryKey: ['health'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); + queryClient.invalidateQueries({ queryKey: ['jobs', {}] }); + queryClient.invalidateQueries({ queryKey: ['certs-by-status'] }); + } + }; + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); + }, [queryClient]); + + // refetchInterval returns false (paused) when the tab is hidden; + // otherwise the per-query base interval applies. + const liveTileGate = (baseMs: number) => (tabVisible ? baseMs : false); + // All hooks must be called unconditionally (React rules of hooks — no hooks after early returns) - const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 }); - const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 }); + const { data: health } = useQuery({ + queryKey: ['health'], queryFn: getHealth, + refetchInterval: liveTileGate(30_000), + refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME, + }); + const { data: summary } = useQuery({ + queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, + refetchInterval: liveTileGate(30_000), + refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME, + }); const { data: issuersData } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); - const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 }); - const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 }); - const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 }); - const { data: issuanceRate } = useQuery({ queryKey: ['issuance-rate'], queryFn: () => getIssuanceRate(30), refetchInterval: 60000 }); - const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 }); - const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 }); + const { data: statusCounts } = useQuery({ + queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, + refetchInterval: liveTileGate(30_000), + refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME, + }); + const { data: expirationTimeline } = useQuery({ + queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), + refetchInterval: liveTileGate(60_000), + }); + const { data: jobTrends } = useQuery({ + queryKey: ['job-trends'], queryFn: () => getJobTrends(30), + refetchInterval: liveTileGate(30_000), + }); + const { data: issuanceRate } = useQuery({ + queryKey: ['issuance-rate'], queryFn: () => getIssuanceRate(30), + refetchInterval: liveTileGate(60_000), + }); + const { data: certs } = useQuery({ + queryKey: ['certificates', {}], queryFn: () => getCertificates(), + refetchInterval: liveTileGate(30_000), + }); + const { data: jobs } = useQuery({ + queryKey: ['jobs', {}], queryFn: () => getJobs(), + refetchInterval: liveTileGate(30_000), // PERF-H1: 10s → 30s + refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME, + }); // Detect first-run ONCE: no user-configured issuers AND no certificates. // Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot. diff --git a/web/src/pages/DiscoveryPage.tsx b/web/src/pages/DiscoveryPage.tsx index c78f28a..e597dff 100644 --- a/web/src/pages/DiscoveryPage.tsx +++ b/web/src/pages/DiscoveryPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getDiscoveredCertificates, @@ -138,18 +139,60 @@ export default function DiscoveryPage() { queryFn: () => getAgents({ per_page: '200' }), }); - const claimMutation = useTrackedMutation({ - mutationFn: ({ id, managedCertId }: { id: string; managedCertId: string }) => + // Phase 2 TQ-M3 closure: claim + dismiss with optimistic updates. + // Each one flips the row's status in the ['discovered-certificates'] + // cache immediately so the visual response is sub-100ms regardless + // of network RTT. Rollback restores the snapshot + fires a Sonner + // error toast. + const queryClient = useQueryClient(); + type DiscSnapshot = { + prev?: { data: DiscoveredCertificate[]; total: number } | undefined; + }; + + const claimMutation = useTrackedMutation({ + mutationFn: ({ id, managedCertId }) => claimDiscoveredCertificate(id, managedCertId), invalidates: [['discovered-certificates'], ['discovery-summary']], + onMutate: async ({ id }): Promise => { + await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] }); + const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev']; + if (prev) { + queryClient.setQueryData(['discovered-certificates'], { + ...prev, + data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Managed' as const } : c)), + }); + } + return { prev }; + }, + onError: (err, _vars, snap) => { + if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev); + toast.error(`Claim failed: ${err.message}`); + }, onSuccess: () => { + toast.success('Certificate claimed'); setClaimingCert(null); }, }); - const dismissMutation = useTrackedMutation({ + const dismissMutation = useTrackedMutation({ mutationFn: dismissDiscoveredCertificate, invalidates: [['discovered-certificates'], ['discovery-summary']], + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] }); + const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev']; + if (prev) { + queryClient.setQueryData(['discovered-certificates'], { + ...prev, + data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Dismissed' as const } : c)), + }); + } + return { prev }; + }, + onError: (err, _id, snap) => { + if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev); + toast.error(`Dismiss failed: ${err.message}`); + }, + onSuccess: () => toast.success('Discovery dismissed'), }); const formatExpiry = (notAfter?: string) => { diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index f6add5d..7f79c0b 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getNotifications, markNotificationRead, requeueNotification } from '../api/client'; import PageHeader from '../components/PageHeader'; @@ -8,6 +9,14 @@ import ErrorState from '../components/ErrorState'; import { timeAgo } from '../api/utils'; import type { Notification } from '../api/types'; +// Phase 2 TQ-M3 closure: optimistic-update context shape. onMutate +// snapshots the current ['notifications', tab] cache; onError uses +// it to roll back. onSettled fires the invalidation regardless. +interface NotifSnapshot { + prevAll?: { data: Notification[]; total: number } | undefined; + prevDead?: { data: Notification[]; total: number } | undefined; +} + type ViewMode = 'list' | 'grouped'; // I-005: the Notifications page now hosts two tabs. "all" is the pre-I-005 @@ -43,9 +52,37 @@ export default function NotificationsPage() { refetchInterval: 30000, }); - const markRead = useTrackedMutation({ + // Phase 2 TQ-M3 closure: mark-notification-read with optimistic + // update. Flip the row's status to 'read' in the cache immediately; + // on error, restore the snapshot + show the toast. The success + // toast is omitted (the visual flip from unread → read is its own + // feedback); errors get a toast because they re-render the row + // back to unread and the operator needs to know why. + const queryClient = useQueryClient(); + const markRead = useTrackedMutation({ mutationFn: markNotificationRead, invalidates: [['notifications']], + onMutate: async (id: string): Promise => { + // Cancel any in-flight refetch so optimistic data doesn't get + // overwritten by a stale response landing during the mutation. + await queryClient.cancelQueries({ queryKey: ['notifications'] }); + const snapshot: NotifSnapshot = { + prevAll: queryClient.getQueryData(['notifications', 'all']) as NotifSnapshot['prevAll'], + prevDead: queryClient.getQueryData(['notifications', 'dead']) as NotifSnapshot['prevDead'], + }; + const flipStatus = (page?: { data: Notification[]; total: number }) => + page + ? { ...page, data: page.data.map((n) => (n.id === id ? { ...n, status: 'read' as const } : n)) } + : page; + queryClient.setQueryData(['notifications', 'all'], flipStatus(snapshot.prevAll)); + queryClient.setQueryData(['notifications', 'dead'], flipStatus(snapshot.prevDead)); + return snapshot; + }, + onError: (err, _id, snapshot) => { + if (snapshot?.prevAll) queryClient.setQueryData(['notifications', 'all'], snapshot.prevAll); + if (snapshot?.prevDead) queryClient.setQueryData(['notifications', 'dead'], snapshot.prevDead); + toast.error(`Mark-read failed: ${err.message}`); + }, }); // I-005: requeue a dead notification. Invalidates both tab cache entries diff --git a/web/src/pages/OnboardingWizard.tsx b/web/src/pages/OnboardingWizard.tsx index 60971b7..da40245 100644 --- a/web/src/pages/OnboardingWizard.tsx +++ b/web/src/pages/OnboardingWizard.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; +import { STALE_TIME } from '../api/queryConstants'; import { useNavigate, Link } from 'react-router-dom'; import { getIssuers, getAgents, getProfiles, getOwners, getTeams, getRenewalPolicies, @@ -284,11 +285,20 @@ function AgentStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void const apiKey = getApiKey() || ''; const serverUrl = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8443` : 'http://localhost:8443'; - // Poll for agents every 5s + // Phase 2 TQ-H1 closure: poll every 5s ONLY until the first agent + // registers, then stop. v5 functional refetchInterval returns false + // (or 0) to disable. Pre-fix this polled forever; once the wizard + // succeeded the next user landed in a state with a 5-second cadence + // hitting /api/v1/agents indefinitely until they reloaded the tab. + // Now: as soon as agents.length > 0, the interval flips to false + // and the poll stops. const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), - refetchInterval: 5000, + refetchInterval: (query) => + (query.state.data?.data?.length ?? 0) > 0 ? false : 5_000, + refetchOnWindowFocus: true, + staleTime: STALE_TIME.REAL_TIME, }); const agentList = agents?.data || []; diff --git a/web/src/pages/auth/ApprovalsPage.tsx b/web/src/pages/auth/ApprovalsPage.tsx index 96faa5f..4097fbf 100644 --- a/web/src/pages/auth/ApprovalsPage.tsx +++ b/web/src/pages/auth/ApprovalsPage.tsx @@ -10,6 +10,7 @@ import { import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 9 + Phase 10 — Approvals queue. @@ -239,7 +240,7 @@ export default function ApprovalsPage() { const query = useQuery({ queryKey: ['approvals', filterState], queryFn: () => listApprovals(filterState), - staleTime: 15_000, + staleTime: STALE_TIME.REAL_TIME, // approval queue — operator-facing refetchInterval: 30_000, }); diff --git a/web/src/pages/auth/AuthSettingsPage.tsx b/web/src/pages/auth/AuthSettingsPage.tsx index a63f8cb..75aa9cd 100644 --- a/web/src/pages/auth/AuthSettingsPage.tsx +++ b/web/src/pages/auth/AuthSettingsPage.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 10 — AuthSettingsPage (stub). @@ -24,7 +25,7 @@ export default function AuthSettingsPage() { const bootstrapQuery = useQuery({ queryKey: ['auth', 'bootstrap', 'available'], queryFn: authBootstrapAvailable, - staleTime: 60_000, + staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data retry: 0, }); // Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated @@ -33,7 +34,7 @@ export default function AuthSettingsPage() { const runtimeQuery = useQuery({ queryKey: ['auth', 'runtime-config'], queryFn: authRuntimeConfig, - staleTime: 60_000, + staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data retry: 0, }); diff --git a/web/src/pages/auth/KeysPage.tsx b/web/src/pages/auth/KeysPage.tsx index 1941bd6..f6de634 100644 --- a/web/src/pages/auth/KeysPage.tsx +++ b/web/src/pages/auth/KeysPage.tsx @@ -13,6 +13,7 @@ import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import ConfirmDialog from '../../components/ConfirmDialog'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 10 — KeysPage. @@ -35,12 +36,12 @@ export default function KeysPage() { const keysQuery = useQuery({ queryKey: ['auth', 'keys'], queryFn: authListKeys, - staleTime: 30_000, + staleTime: STALE_TIME.REAL_TIME, // operator-facing live data }); const rolesQuery = useQuery({ queryKey: ['auth', 'roles'], queryFn: authListRoles, - staleTime: 60_000, + staleTime: STALE_TIME.REFERENCE, // role catalogue, slow-changing }); const [assignTarget, setAssignTarget] = useState(null); diff --git a/web/src/pages/auth/OIDCJWKSStatusPanel.tsx b/web/src/pages/auth/OIDCJWKSStatusPanel.tsx index 98be3b3..2721f69 100644 --- a/web/src/pages/auth/OIDCJWKSStatusPanel.tsx +++ b/web/src/pages/auth/OIDCJWKSStatusPanel.tsx @@ -5,6 +5,7 @@ import { refreshOIDCProvider, type JWKSStatusSnapshot, } from '../../api/client'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Audit 2026-05-11 Fix 10 — JWKS health panel (MED-7 GUI half). @@ -69,7 +70,7 @@ export default function OIDCJWKSStatusPanel({ providerID, canRefresh = true }: P queryKey: ['auth', 'oidc', 'jwks-status', providerID], queryFn: () => authOIDCJWKSStatus(providerID), // 30s freshness — operators rarely poll faster than this. - staleTime: 30_000, + staleTime: STALE_TIME.REAL_TIME, // operator troubleshooting key rotation // 403 / 404 / 500 — don't drown the page in retries. The panel // hides itself on error (see below). retry: 0, diff --git a/web/src/pages/auth/RoleDetailPage.tsx b/web/src/pages/auth/RoleDetailPage.tsx index 1d6f4fb..cc4cc27 100644 --- a/web/src/pages/auth/RoleDetailPage.tsx +++ b/web/src/pages/auth/RoleDetailPage.tsx @@ -15,6 +15,7 @@ import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import ConfirmDialog from '../../components/ConfirmDialog'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 10 — RoleDetailPage. @@ -56,12 +57,12 @@ export default function RoleDetailPage() { queryKey: ['auth', 'role', id], queryFn: () => authGetRole(id), enabled: Boolean(id), - staleTime: 30_000, + staleTime: STALE_TIME.REAL_TIME, // operator editing — fresh data }); const permsCatalogue = useQuery({ queryKey: ['auth', 'permissions'], queryFn: authListPermissions, - staleTime: 5 * 60_000, + staleTime: STALE_TIME.CONSTANT, // catalogue — effectively immutable }); const [editOpen, setEditOpen] = useState(false); diff --git a/web/src/pages/auth/RolesPage.tsx b/web/src/pages/auth/RolesPage.tsx index fb69084..826ac7f 100644 --- a/web/src/pages/auth/RolesPage.tsx +++ b/web/src/pages/auth/RolesPage.tsx @@ -5,6 +5,7 @@ import { authListRoles, authCreateRole, type AuthRole } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 10 — RolesPage. @@ -139,7 +140,7 @@ export default function RolesPage() { const rolesQuery = useQuery({ queryKey: ['auth', 'roles'], queryFn: authListRoles, - staleTime: 30_000, + staleTime: STALE_TIME.REFERENCE, // role catalogue — slow-changing }); const [createOpen, setCreateOpen] = useState(false); diff --git a/web/src/pages/auth/UsersPage.tsx b/web/src/pages/auth/UsersPage.tsx index cbead69..13ca683 100644 --- a/web/src/pages/auth/UsersPage.tsx +++ b/web/src/pages/auth/UsersPage.tsx @@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { authListUsers, authDeactivateUser, authReactivateUser, type AuthUser } from '../../api/client'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; +import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Audit 2026-05-10 MED-11 closure — Federated-user admin GUI. @@ -26,7 +27,7 @@ export default function UsersPage() { const usersQuery = useQuery({ queryKey: ['auth', 'users', providerFilter], queryFn: () => authListUsers(providerFilter || undefined), - staleTime: 30_000, + staleTime: STALE_TIME.REAL_TIME, // operator-facing user list }); async function deactivate(u: AuthUser) {