perf(web): lazy admin context + instant shell for software mapping

AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.

/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.

Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-29 17:21:51 +02:00
parent 907118649b
commit 5191751ed2
16 changed files with 117 additions and 39 deletions
+3
View File
@@ -148,3 +148,6 @@ plans/REFACTOR_AGENT_V2.md
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -83,6 +83,12 @@ interface AdminConfigContextValue {
excessiveEventAutoActionDurationHours: number,
) => Promise<void>;
// Lazy loaders — config/tenants are NOT fetched when the admin layout mounts.
// Sections that consume adminConfig/tenants call these once on mount; pages that
// only need getAccessToken/notifications (e.g. Software Mapping) pay nothing.
ensureAdminConfigLoaded: () => void;
ensureTenantsLoaded: () => void;
// Tenants
tenants: TenantConfiguration[];
setTenants: React.Dispatch<React.SetStateAction<TenantConfiguration[]>>;
@@ -170,11 +176,11 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const isGlobalAdmin = user?.isGlobalAdmin === true;
// Fetch admin configuration
useEffect(() => {
if (!isGlobalAdmin) return;
// Fetch admin configuration — lazy: runs once, triggered by the first section
// that actually consumes adminConfig (via ensureAdminConfigLoaded).
const adminConfigRequested = useRef(false);
const fetchAdminConfig = async () => {
const fetchAdminConfig = useCallback(async () => {
try {
setLoadingConfig(true);
setError(null);
@@ -232,14 +238,23 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setLoadingConfig(false);
}
};
}, [getAccessToken]);
const ensureAdminConfigLoaded = useCallback(() => {
// Global Reader never loads platform config (same as the old eager gate);
// backend would 403/redact anyway.
if (!isGlobalAdmin || adminConfigRequested.current) return;
adminConfigRequested.current = true;
fetchAdminConfig();
}, [isGlobalAdmin, getAccessToken]);
}, [isGlobalAdmin, fetchAdminConfig]);
// Fetch tenants + preview whitelist. Exposed directly as the explicit refresh
// (post-save reloads); ensureTenantsLoaded is the idempotent on-mount trigger.
const tenantsRequested = useRef(false);
// Fetch tenants + preview whitelist
const fetchTenants = useCallback(async () => {
if (!isGlobalAdmin) return;
tenantsRequested.current = true;
try {
setLoadingTenants(true);
@@ -274,7 +289,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
}
}, [isGlobalAdmin, getAccessToken]);
useEffect(() => {
const ensureTenantsLoaded = useCallback(() => {
if (tenantsRequested.current) return;
fetchTenants();
}, [fetchTenants]);
@@ -508,6 +524,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
excessiveEventAutoActionThreshold,
excessiveEventAutoActionDurationHours,
savingOpsAlerts,
ensureAdminConfigLoaded, ensureTenantsLoaded,
tenants, setTenants, loadingTenants, fetchTenants,
previewApproved, setPreviewApproved,
error, setError, successMessage, setSuccessMessage,
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { trackEvent } from "@/lib/appInsights";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
interface IgnoredSoftwareTabProps {
@@ -27,7 +28,8 @@ export function IgnoredSoftwareTab({
onCountChanged,
}: IgnoredSoftwareTabProps) {
const [ignoredEntries, setIgnoredEntries] = useState<IgnoredSoftwareEntry[]>([]);
const [ignoredLoading, setIgnoredLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [ignoredLoading, setIgnoredLoading] = useState(true);
const [ignoredLoaded, setIgnoredLoaded] = useState(false);
const [ignoredPage, setIgnoredPage] = useState(0);
const [restoringRow, setRestoringRow] = useState<string | null>(null);
@@ -56,10 +58,11 @@ export function IgnoredSoftwareTab({
// Lazy load on mount
useEffect(() => {
if (!ignoredLoaded && !ignoredLoading) {
if (!ignoredLoaded) {
fetchIgnoredSoftware();
}
}, [ignoredLoaded, ignoredLoading, fetchIgnoredSoftware]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ignoredLoaded]);
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
useEffect(() => {
@@ -158,11 +161,9 @@ export function IgnoredSoftwareTab({
return (
<>
{ignoredLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading ignored software...</span>
</div>
{ignoredLoading && !ignoredLoaded ? (
// Skeleton only on initial load; refetches (restore) keep the table visible.
<TableSkeleton columns={4} rows={8} className="pt-4" />
) : ignoredEntries.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { CpeMappingEntry } from "./SoftwareMappingTypes";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface MappedSoftwareTabProps {
getAccessToken: () => Promise<string | null>;
@@ -22,7 +23,8 @@ export function MappedSoftwareTab({
onCountChanged,
}: MappedSoftwareTabProps) {
const [mappedEntries, setMappedEntries] = useState<CpeMappingEntry[]>([]);
const [mappedLoading, setMappedLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [mappedLoading, setMappedLoading] = useState(true);
const [mappedLoaded, setMappedLoaded] = useState(false);
const [mappedPage, setMappedPage] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
@@ -80,10 +82,11 @@ export function MappedSoftwareTab({
// Lazy load on first render
useEffect(() => {
if (!mappedLoaded && !mappedLoading) {
if (!mappedLoaded) {
fetchCpeMappings();
}
}, [mappedLoaded, mappedLoading, fetchCpeMappings]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mappedLoaded]);
// Reset loaded state when refreshTrigger changes (external signal to reload)
useEffect(() => {
@@ -285,13 +288,9 @@ export function MappedSoftwareTab({
// --- Render ---
if (mappedLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading CPE mappings...</span>
</div>
);
// Skeleton only on initial load; refetches (search, refresh) keep the table visible.
if (mappedLoading && !mappedLoaded) {
return <TableSkeleton columns={5} rows={8} className="pt-4" />;
}
return (
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { trackEvent } from "@/lib/appInsights";
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
@@ -27,7 +28,8 @@ export function UnmappedSoftwareTab({
}: UnmappedSoftwareTabProps) {
// Unmapped state — server-side pagination
const pageSize = 20;
const [loading, setLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [loading, setLoading] = useState(true);
const [initialLoaded, setInitialLoaded] = useState(false);
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -378,15 +380,12 @@ export function UnmappedSoftwareTab({
// --- Render ---
// Full-page spinner only on initial load. Subsequent page fetches keep the table visible
// Table skeleton only on initial load. Subsequent page fetches keep the table visible
// to avoid flicker; controls are disabled via the `loading` state.
return (
<>
{loading && !initialLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
</div>
<TableSkeleton columns={6} rows={8} className="pt-4" />
) : initialLoaded && total === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../AdminConfigContext";
import { MaintenanceTriggerSection } from "../components/MaintenanceTriggerSection";
import { OpsEventsSection } from "../components/OpsEventsSection";
@@ -7,6 +8,7 @@ import { AdminNotifications } from "../AdminNotifications";
export default function OpsPage() {
const {
ensureAdminConfigLoaded,
getAccessToken,
setError,
setSuccessMessage,
@@ -16,6 +18,9 @@ export default function OpsPage() {
savingConfig,
} = useAdminConfig();
// opsEventRetentionDays + handleSaveAdminConfig operate on adminConfig
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
@@ -57,7 +57,9 @@ interface TenantsWithManifestsResponse {
* that opens the dialog with the selection pre-filled.
*/
export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage }: RestoreBrowserTabProps) {
const { tenants, loadingTenants } = useAdminConfig();
const { ensureTenantsLoaded, tenants, loadingTenants } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
// loadedTenantId pairs the displayed sessions with the tenant they belong to. The dropdown's
// selectedTenantId can race ahead of an in-flight fetch; rendering only when the two match
@@ -1,11 +1,15 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { SessionExportSection } from "../../components/SessionExportSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionSessionExport() {
const { tenants, getAccessToken } = useAdminConfig();
const { ensureTenantsLoaded, tenants, getAccessToken } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<>
<AdminNotifications />
@@ -1,11 +1,14 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { DeviceBlockSection } from "../../components/DeviceBlockSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionDeviceBlock() {
const { tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
const { ensureTenantsLoaded, tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<>
@@ -1,11 +1,15 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { VulnerabilityDataSection } from "../../components/VulnerabilityDataSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionVulnerabilityData() {
const { adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
const { ensureAdminConfigLoaded, adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { OpsAlertRulesSection } from "../../components/OpsAlertRulesSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionAlerts() {
const {
ensureAdminConfigLoaded,
loadingConfig,
savingOpsAlerts,
adminConfig,
@@ -23,6 +25,8 @@ export function SectionAlerts() {
handleSaveOpsAlertConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -1,11 +1,14 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { DiagnosticsLogPathsSection } from "../../components/DiagnosticsLogPathsSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionDiagnosticsLogPaths() {
const { globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
const { ensureAdminConfigLoaded, globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminConfigSettingsSection } from "../../components/AdminConfigSettingsSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionGlobalSettings() {
const {
ensureAdminConfigLoaded,
loadingConfig, savingConfig, adminConfig,
globalRateLimit, setGlobalRateLimit,
userRateLimit, setUserRateLimit,
@@ -23,6 +25,8 @@ export function SectionGlobalSettings() {
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -23,7 +23,12 @@ const DEFAULT_TIER: PlanTierDefinition = {
export function SectionUsagePlans() {
const { getAccessToken } = useAuth();
const { tenants, userRateLimit } = useAdminConfig();
const { ensureAdminConfigLoaded, ensureTenantsLoaded, tenants, userRateLimit } = useAdminConfig();
useEffect(() => {
ensureAdminConfigLoaded();
ensureTenantsLoaded();
}, [ensureAdminConfigLoaded, ensureTenantsLoaded]);
const [tiers, setTiers] = useState<PlanTierDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -0,0 +1,21 @@
import { Bar } from "@/components/skeletons/PageSkeleton";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
// Route-level fallback matching the Software Mapping page shell: header band +
// mapping card with table. Paints during navigation, before the client bundle
// hydrates and the unmatched-software fetch resolves.
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900" aria-busy="true" aria-label="Loading">
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
<div className="py-6 px-4 sm:px-6 lg:px-8 space-y-3">
<Bar className="h-7 w-56" />
<Bar className="h-3 w-80" />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TableSkeleton columns={6} rows={10} wrapped />
</main>
</div>
);
}
@@ -1,10 +1,12 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { TenantManagementSection } from "../../components/TenantManagementSection";
export function SectionTenantManagement() {
const {
ensureTenantsLoaded,
tenants,
loadingTenants,
fetchTenants,
@@ -16,6 +18,8 @@ export function SectionTenantManagement() {
setSuccessMessage,
} = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<TenantManagementSection
tenants={tenants}