diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx index 7a3a09bb..5bcd92c4 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; interface DistressReport { tenantId: string; @@ -108,7 +109,9 @@ export function DistressReportsSection({ setError, }: DistressReportsSectionProps) { const [reports, setReports] = useState([]); - const [loading, setLoading] = useState(false); + // true from the start: the fetch fires on mount, and a false initial value + // flashes the "No distress reports" empty state for one paint. + const [loading, setLoading] = useState(true); const [selectedReport, setSelectedReport] = useState(null); const [currentPage, setCurrentPage] = useState(0); const [sortKey, setSortKey] = useState("ingestedAt"); @@ -203,7 +206,9 @@ export function DistressReportsSection({ - {/* Summary Cards */} + {/* Summary Cards — intentionally hidden while loading: distress is often + empty on a healthy system, and a skeleton band that then vanishes + shifts the layout more than one that appears. */} {!loading && reports.length > 0 && (
@@ -234,10 +239,7 @@ export function DistressReportsSection({ {/* Reports Table */}
{loading ? ( -
-
- Loading distress reports... -
+ ) : reports.length === 0 ? (
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx index 48ab1eab..9aad51c1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx @@ -28,7 +28,9 @@ interface FeedbackSectionProps { } export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) { - const [loading, setLoading] = useState(false); + // true from the start: the fetch fires on mount, and a false initial value + // flashes the empty state for one paint. + const [loading, setLoading] = useState(true); const [entries, setEntries] = useState([]); const [activeTab, setActiveTab] = useState("InApp"); const [inAppFilter, setInAppFilter] = useState("all"); diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index 6eb6e9fe..9564772f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -8,6 +8,7 @@ import { extractContinuation } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; import { trackEvent } from "@/lib/appInsights"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; +import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; const PAGE_SIZE = 20; @@ -83,7 +84,9 @@ export function SessionReportsSection({ // Reader may view reports + notes but not edit them. const canMutate = useCanMutatePlatform(); const [reports, setReports] = useState([]); - const [loading, setLoading] = useState(false); + // true from the start: the fetch fires on mount, and a false initial value + // flashes the empty state for one paint. + const [loading, setLoading] = useState(true); const [selectedReport, setSelectedReport] = useState(null); const [downloadingBlob, setDownloadingBlob] = useState(null); const [adminNoteValue, setAdminNoteValue] = useState(""); @@ -310,11 +313,10 @@ export function SessionReportsSection({ {/* Reports Table */}
- {loading ? ( -
-
- Loading reports... -
+ {/* Skeleton only on the initial (empty) load — a Refresh keeps the + populated table on screen; the button already shows "Loading...". */} + {loading && reports.length === 0 ? ( + ) : reports.length === 0 ? (
diff --git a/src/Web/autopilot-monitor-web/app/admin/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/loading.tsx new file mode 100644 index 00000000..a7dde912 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/loading.tsx new file mode 100644 index 00000000..b51394d8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/loading.tsx @@ -0,0 +1,5 @@ +import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/reports/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/reports/loading.tsx new file mode 100644 index 00000000..824b9ba0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/reports/loading.tsx @@ -0,0 +1,5 @@ +import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/security/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/security/loading.tsx new file mode 100644 index 00000000..b51394d8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/security/loading.tsx @@ -0,0 +1,5 @@ +import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/loading.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/loading.tsx new file mode 100644 index 00000000..b51394d8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/settings/loading.tsx @@ -0,0 +1,5 @@ +import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton"; + +export default function Loading() { + 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 4497f953..4ff18aaf 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 @@ -5,6 +5,7 @@ import { useAuth } from "../../../../contexts/AuthContext"; import { useAdminConfig } from "../../AdminConfigContext"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { api } from "@/lib/api"; +import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; interface PlanTierDefinition { name: string; @@ -117,15 +118,6 @@ export function SectionUsagePlans() { setHasChanges(true); }; - if (loading) { - return ( -
-
- Loading plan definitions... -
- ); - } - return (
{/* Header */} @@ -139,7 +131,8 @@ export function SectionUsagePlans() {
@@ -175,8 +168,11 @@ export function SectionUsagePlans() {
)} + {/* Initial load — header stays visible, tier list gets the skeleton */} + {loading && } + {/* Empty State */} - {tiers.length === 0 && ( + {!loading && tiers.length === 0 && (

No plan tiers defined yet.

- {/* Audit Table */} + {/* Audit Table — skeleton on the initial load only; refetches use + `refreshing` and keep the populated table on screen. */}
- {filteredLogs.length === 0 ? ( + {loading ? ( + + ) : filteredLogs.length === 0 ? (
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index b63bb561..ef84dead 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -25,6 +25,7 @@ import { useDashboardFilters } from "./hooks/useDashboardFilters"; import { useDashboardSessions } from "./hooks/useDashboardSessions"; import { useDashboardStats } from "./hooks/useDashboardStats"; import { DOCS_URL } from "@/utils/config"; +import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; export default function Home() { // useSearchParams() in HomeContent requires a Suspense boundary for static prerender. @@ -425,7 +426,12 @@ function HomeContent() { {/* Welcome message - only show when no sessions */} {!loading && sessions.length === 0 && } - {/* Sessions List */} + {/* Sessions List — skeleton fills the slot on the initial fetch so + the page has no blank gap; once data (or the empty states above) + resolve, it never reappears. */} + {loading && sessions.length === 0 && ( + + )} {sessions.length > 0 && ( -
Loading fleet health data...
-
- ); - } + // Skeleton on the initial load and while a scope/range switch refetches (data + // is reset to null) — the header with the scope/range controls stays + // interactive. On error the shell renders with empty data; the hook surfaces + // the failure via a notification, matching the previous behavior. + const pendingInitial = !data && !error; return ( @@ -287,6 +284,17 @@ export default function FleetHealthPage() {
+ {pendingInitial ? ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ ) : ( + <> {/* Top Stats */}
)}
+ + )}
diff --git a/src/Web/autopilot-monitor-web/app/fleet/loading.tsx b/src/Web/autopilot-monitor-web/app/fleet/loading.tsx new file mode 100644 index 00000000..7c74abb3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/loading.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/loading.tsx new file mode 100644 index 00000000..64aed1ba --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/gather-rules/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/loading.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/loading.tsx new file mode 100644 index 00000000..7470bf44 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/loading.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/loading.tsx new file mode 100644 index 00000000..64aed1ba --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/health-check/loading.tsx b/src/Web/autopilot-monitor-web/app/health-check/loading.tsx new file mode 100644 index 00000000..7470bf44 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/health-check/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/ime-log-patterns/loading.tsx b/src/Web/autopilot-monitor-web/app/ime-log-patterns/loading.tsx new file mode 100644 index 00000000..64aed1ba --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/ime-log-patterns/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/preview/loading.tsx b/src/Web/autopilot-monitor-web/app/preview/loading.tsx new file mode 100644 index 00000000..7470bf44 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/preview/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/progress/loading.tsx b/src/Web/autopilot-monitor-web/app/progress/loading.tsx new file mode 100644 index 00000000..7470bf44 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/progress/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/settings/loading.tsx b/src/Web/autopilot-monitor-web/app/settings/loading.tsx new file mode 100644 index 00000000..a7dde912 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/sla/loading.tsx b/src/Web/autopilot-monitor-web/app/sla/loading.tsx new file mode 100644 index 00000000..7c74abb3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sla/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/sla/page.tsx b/src/Web/autopilot-monitor-web/app/sla/page.tsx index 7ffcf39b..983bc958 100644 --- a/src/Web/autopilot-monitor-web/app/sla/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sla/page.tsx @@ -28,6 +28,7 @@ import { useGlobalAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import Link from "next/link"; +import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; interface SlaSnapshot { week: string; @@ -185,17 +186,6 @@ export default function SlaPage() { (metrics.targetSuccessRate != null || metrics.targetMaxDurationMinutes != null || metrics.targetAppInstallSuccessRate != null); - if (loading && !metrics) { - return ( -
-
-
-

Loading SLA metrics...

-
-
- ); - } - return (
@@ -258,8 +248,18 @@ export default function SlaPage() {
- {/* No targets state */} - {!hasTargets && ( + {/* Initial load — header with scope/months controls stays interactive */} + {loading && !metrics && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {/* No targets state — suppressed while the initial fetch is pending + so it doesn't flash before metrics arrive */} + {!(loading && !metrics) && !hasTargets && (
diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/loading.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/loading.tsx new file mode 100644 index 00000000..5898aaf4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from "@/components/skeletons/PageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index 3c0ee54b..b8afb881 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -10,6 +10,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch" import { useGlobalAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; +import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; interface SessionMetrics { total: number; @@ -169,21 +170,6 @@ export default function UsageMetricsPage() { return date.toLocaleString(); }; - if (loading) { - return ( -
-
-
-

Loading usage metrics...

-
-
- ); - } - - if (!metrics) { - return null; - } - return (
@@ -198,12 +184,16 @@ export default function UsageMetricsPage() { {isGlobalOverride && selectedTenantName ? `Tenant: ${selectedTenantName} · ` : ''} - Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms - {metrics.fromCache && ( - - From Cache - - )} + {metrics ? ( + <> + Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms + {metrics.fromCache && ( + + From Cache + + )} + + ) : '…'}

@@ -224,6 +214,26 @@ export default function UsageMetricsPage() {
+ {/* Skeleton also covers a tenant switch (loading with stale metrics) so + the previous tenant's numbers never linger under the new scope. */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : !metrics ? ( +
+

Unable to load usage metrics.

+ +
+ ) : ( + <> {/* Session Statistics */}
@@ -489,6 +499,8 @@ export default function UsageMetricsPage() {
+ + )}
diff --git a/src/Web/autopilot-monitor-web/components/skeletons/PageSkeleton.tsx b/src/Web/autopilot-monitor-web/components/skeletons/PageSkeleton.tsx index e593ae41..37f91d62 100644 --- a/src/Web/autopilot-monitor-web/components/skeletons/PageSkeleton.tsx +++ b/src/Web/autopilot-monitor-web/components/skeletons/PageSkeleton.tsx @@ -3,7 +3,7 @@ // destination's client bundle downloads and hydrates. Shared shell used by the // per-route loading.tsx files. -function Bar({ className = "" }: { className?: string }) { +export function Bar({ className = "" }: { className?: string }) { return (
diff --git a/src/Web/autopilot-monitor-web/components/skeletons/SectionSkeleton.tsx b/src/Web/autopilot-monitor-web/components/skeletons/SectionSkeleton.tsx new file mode 100644 index 00000000..a243a9f9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/skeletons/SectionSkeleton.tsx @@ -0,0 +1,39 @@ +// Server component — card-wrapped table skeleton with an optional title bar. +// Used by admin-area loading.tsx fallbacks (renders inside the persistent area +// sidebar layout) and by section components while their data loads. + +import { Bar } from "./PageSkeleton"; +import { TableSkeleton } from "./TableSkeleton"; + +interface SectionSkeletonProps { + columns?: number; + rows?: number; + /** Render a title bar above the table. */ + title?: boolean; + className?: string; +} + +export function SectionSkeleton({ + columns = 6, + rows = 8, + title = true, + className = "", +}: SectionSkeletonProps) { + return ( +
+ {title && ( +
+ + +
+ )} + +
+ ); +} + +export default SectionSkeleton; diff --git a/src/Web/autopilot-monitor-web/components/skeletons/TableSkeleton.tsx b/src/Web/autopilot-monitor-web/components/skeletons/TableSkeleton.tsx new file mode 100644 index 00000000..87edd383 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/skeletons/TableSkeleton.tsx @@ -0,0 +1,82 @@ +// Server component (no "use client", no hooks) so it can serve both as part of +// a route-level loading.tsx fallback and as the in-page data-slot placeholder +// while a client component fetches. +// +// Loading-UX convention (applies to every page with fetched table data): +// 1. Never early-return the whole page on `loading` — render the page shell +// and put this skeleton in the data slot only. +// 2. Any loading UI must live INSIDE ; an early return above +// it hangs forever on a fresh tab because the MSAL redirect never fires +// (see the comment in app/diagnosis/page.tsx). +// 3. Show the skeleton only when `loading && items.length === 0` so a +// filter-driven refetch keeps the populated table on screen. +// 4. Initialise `loading` to true when a fetch fires on mount, otherwise the +// empty state flashes for one paint before the fetch starts. + +import { Bar } from "./PageSkeleton"; + +interface TableSkeletonProps { + /** Number of columns to render. */ + columns?: number; + /** Number of placeholder body rows. */ + rows?: number; + /** Render a shimmer header row. */ + header?: boolean; + /** Wrap in the standard card chrome (rounded border + bg + shadow). */ + wrapped?: boolean; + className?: string; +} + +// Deterministic pseudo-random widths so rows look organic without hydration +// mismatches (no Math.random in render). +const WIDTHS = ["w-24", "w-16", "w-32", "w-20", "w-28", "w-12", "w-36"]; + +export function TableSkeleton({ + columns = 5, + rows = 8, + header = true, + wrapped = false, + className = "", +}: TableSkeletonProps) { + const table = ( +
+ + {header && ( + + + {Array.from({ length: columns }).map((_, c) => ( + + ))} + + + )} + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: columns }).map((_, c) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); + + if (!wrapped) { + return className ?
{table}
: table; + } + return ( +
+ {table} +
+ ); +} + +export default TableSkeleton; diff --git a/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts b/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts index 364bab42..feac3c35 100644 --- a/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts @@ -7,7 +7,7 @@ export const PAGE_LASTMOD: Record = { "/about": "2026-07-25T12:43:42+02:00", "/roadmap": "2026-07-06T18:31:00+02:00", "/changelog": "2026-07-06T18:12:31+02:00", - "/privacy": "2026-07-25T12:43:42+02:00", + "/privacy": "2026-07-29T13:53:12+02:00", "/terms": "2026-07-23T08:37:42+02:00", "/docs": "2026-07-06T18:12:31+02:00", "/docs/private-preview": "2026-07-06T18:12:31+02:00",