mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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">
|
||||
|
||||
+3
-1
@@ -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 (
|
||||
<>
|
||||
|
||||
+5
-1
@@ -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 />
|
||||
|
||||
+4
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user