diff --git a/.gitignore b/.gitignore index f5090ece..f2096cff 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 837df554..75a5122c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -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; + // 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>; @@ -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, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx index 3a336af0..9c8dcd0f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx @@ -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([]); - 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(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 ? ( -
-
- Loading ignored software... -
+ {ignoredLoading && !ignoredLoaded ? ( + // Skeleton only on initial load; refetches (restore) keep the table visible. + ) : ignoredEntries.length === 0 ? (
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx index 5a0cec2a..82901c58 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx @@ -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; @@ -22,7 +23,8 @@ export function MappedSoftwareTab({ onCountChanged, }: MappedSoftwareTabProps) { const [mappedEntries, setMappedEntries] = useState([]); - 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 ( -
-
- Loading CPE mappings... -
- ); + // Skeleton only on initial load; refetches (search, refresh) keep the table visible. + if (mappedLoading && !mappedLoaded) { + return ; } return ( diff --git a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx index 0e191386..93c5faad 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx @@ -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([]); 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 ? ( -
-
- Loading unmatched software... -
+ ) : initialLoaded && total === 0 ? (
diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx index 568c8a7f..3de028d4 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx @@ -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 (
diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx index 94b538be..714a7350 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx @@ -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(""); // 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 diff --git a/src/Web/autopilot-monitor-web/app/admin/reports/sections/SectionSessionExport.tsx b/src/Web/autopilot-monitor-web/app/admin/reports/sections/SectionSessionExport.tsx index 924778a3..a53eb598 100644 --- a/src/Web/autopilot-monitor-web/app/admin/reports/sections/SectionSessionExport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/reports/sections/SectionSessionExport.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionDeviceBlock.tsx b/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionDeviceBlock.tsx index b931cdbc..06fd6a6a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionDeviceBlock.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionDeviceBlock.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionVulnerabilityData.tsx b/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionVulnerabilityData.tsx index 1972e25d..5892bcc2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionVulnerabilityData.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionVulnerabilityData.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx index 5c623371..aaf2d805 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDiagnosticsLogPaths.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDiagnosticsLogPaths.tsx index a20b5655..f7b4e657 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDiagnosticsLogPaths.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDiagnosticsLogPaths.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx index dbbbe2c9..0f846203 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx @@ -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 ( <> diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx index 4ff18aaf..8d53bea7 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); diff --git a/src/Web/autopilot-monitor-web/app/admin/software/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/software/loading.tsx new file mode 100644 index 00000000..7bd84f48 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/software/loading.tsx @@ -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 ( +
+
+
+ + +
+
+
+ +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantManagement.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantManagement.tsx index 216d31bb..c4182f94 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantManagement.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantManagement.tsx @@ -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 (