feat(frontend): Phase 2 TanStack Query Discipline — close TQ-H1/H2 + TQ-M1/M2/M3 + PERF-H1 + P-H1 + partial TQ-L1

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).
This commit is contained in:
shankar0123
2026-05-14 14:51:49 +00:00
parent c1b581b047
commit 7c01f811a1
16 changed files with 466 additions and 46 deletions
+75
View File
@@ -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 15s5min 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;
+112
View File
@@ -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);
});
});
+20 -3
View File
@@ -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
},
},
});
+22 -3
View File
@@ -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<unknown, Error, void, ArchiveSnapshot>({
mutationFn: () => archiveCertificate(id!),
invalidates: [['certificates']],
onMutate: async (): Promise<ArchiveSnapshot> => {
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({
+39 -12
View File
@@ -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<string, string> = {};
if (statusFilter) params.status = statusFilter;
+83 -10
View File
@@ -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.
+47 -4
View File
@@ -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<unknown, Error, { id: string; managedCertId: string }, DiscSnapshot>({
mutationFn: ({ id, managedCertId }) =>
claimDiscoveredCertificate(id, managedCertId),
invalidates: [['discovered-certificates'], ['discovery-summary']],
onMutate: async ({ id }): Promise<DiscSnapshot> => {
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<unknown, Error, string, DiscSnapshot>({
mutationFn: dismissDiscoveredCertificate,
invalidates: [['discovered-certificates'], ['discovery-summary']],
onMutate: async (id): Promise<DiscSnapshot> => {
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) => {
+39 -2
View File
@@ -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<unknown, Error, string, NotifSnapshot>({
mutationFn: markNotificationRead,
invalidates: [['notifications']],
onMutate: async (id: string): Promise<NotifSnapshot> => {
// 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
+12 -2
View File
@@ -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() || '<your-api-key>';
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 || [];
+2 -1
View File
@@ -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,
});
+3 -2
View File
@@ -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,
});
+3 -2
View File
@@ -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<AuthKeyEntry[], Error>({
queryKey: ['auth', 'keys'],
queryFn: authListKeys,
staleTime: 30_000,
staleTime: STALE_TIME.REAL_TIME, // operator-facing live data
});
const rolesQuery = useQuery<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 60_000,
staleTime: STALE_TIME.REFERENCE, // role catalogue, slow-changing
});
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
+2 -1
View File
@@ -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,
+3 -2
View File
@@ -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<AuthPermission[], Error>({
queryKey: ['auth', 'permissions'],
queryFn: authListPermissions,
staleTime: 5 * 60_000,
staleTime: STALE_TIME.CONSTANT, // catalogue — effectively immutable
});
const [editOpen, setEditOpen] = useState(false);
+2 -1
View File
@@ -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<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 30_000,
staleTime: STALE_TIME.REFERENCE, // role catalogue — slow-changing
});
const [createOpen, setCreateOpen] = useState(false);
+2 -1
View File
@@ -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<AuthUser[], Error>({
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) {