From 26ecbf33d6fee7b4fc012366bf291dfd4a9a7e10 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 29 Jul 2026 15:17:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20instant-shell=20loading=20UX=20?= =?UTF-8?q?=E2=80=94=20table=20skeletons=20+=20route=20loading=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perceived hangs on navigation (e.g. Distress Reports) had three causes: missing loading.tsx fallbacks froze the old page until the new chunk rendered, loading state initialised false flashed empty states for one paint, and several pages blocked their entire render on the first fetch. - New TableSkeleton/SectionSkeleton primitives (server-component-safe), loading-UX convention documented in TableSkeleton.tsx - 20 new route-level loading.tsx fallbacks (admin areas + top-level pages) - Distress/Session Reports + Feedback: loading init true, skeleton in the table slot; refreshes keep the populated table - Dashboard: skeleton in the sessions slot instead of a blank gap - Converted full-page loading early returns on audit (also fixes a latent auth hang: the spinner sat outside ProtectedRoute so a fresh tab never triggered the MSAL redirect), usage-metrics, fleet-health, sla and admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard pages) left untouched Co-Authored-By: Claude Fable 5 --- .../components/DistressReportsSection.tsx | 14 ++-- .../app/admin/components/FeedbackSection.tsx | 4 +- .../components/SessionReportsSection.tsx | 14 ++-- .../app/admin/loading.tsx | 5 ++ .../app/admin/metrics/loading.tsx | 5 ++ .../app/admin/reports/loading.tsx | 5 ++ .../app/admin/security/loading.tsx | 5 ++ .../app/admin/settings/loading.tsx | 5 ++ .../settings/sections/SectionUsagePlans.tsx | 18 ++-- .../app/admin/tenants/loading.tsx | 5 ++ .../app/analyze-rules/loading.tsx | 5 ++ .../app/apps/loading.tsx | 5 ++ .../app/audit/loading.tsx | 5 ++ .../autopilot-monitor-web/app/audit/page.tsx | 27 +++--- .../app/dashboard/page.tsx | 8 +- .../app/fleet-health/page.tsx | 30 ++++--- .../app/fleet/loading.tsx | 5 ++ .../app/gather-rules/loading.tsx | 5 ++ .../app/geographic-performance/loading.tsx | 5 ++ .../sessions/loading.tsx | 5 ++ .../app/health-check/loading.tsx | 5 ++ .../app/ime-log-patterns/loading.tsx | 5 ++ .../app/preview/loading.tsx | 5 ++ .../app/progress/loading.tsx | 5 ++ .../app/settings/loading.tsx | 5 ++ .../autopilot-monitor-web/app/sla/loading.tsx | 5 ++ .../autopilot-monitor-web/app/sla/page.tsx | 26 +++--- .../app/usage-metrics/loading.tsx | 5 ++ .../app/usage-metrics/page.tsx | 54 +++++++----- .../components/skeletons/PageSkeleton.tsx | 4 +- .../components/skeletons/SectionSkeleton.tsx | 39 +++++++++ .../components/skeletons/TableSkeleton.tsx | 82 +++++++++++++++++++ .../utils/page-lastmod.generated.ts | 2 +- 33 files changed, 335 insertions(+), 87 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/admin/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/metrics/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/reports/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/security/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/settings/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/tenants/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/analyze-rules/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/apps/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/audit/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/fleet/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/gather-rules/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/geographic-performance/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/geographic-performance/sessions/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/health-check/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/ime-log-patterns/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/preview/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/progress/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/settings/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sla/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/app/usage-metrics/loading.tsx create mode 100644 src/Web/autopilot-monitor-web/components/skeletons/SectionSkeleton.tsx create mode 100644 src/Web/autopilot-monitor-web/components/skeletons/TableSkeleton.tsx 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",