mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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:
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user