From 071cba35bc703b7878c7d1666d88b0dfb54ff079 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 31 Jul 2026 00:14:10 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20typed=20routes=20=E2=80=94=20b?= =?UTF-8?q?roken=20internal=20links=20are=20now=20compile=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typedRoutes: true (stable in Next 16; `next typegen` already runs in CI before tsc since #114). A typo'd /router.push target now fails the build instead of 404ing at runtime — the failure class behind the recent RSC/routing incidents. - lib/routes.ts: registry helpers return `Route`; withQuery validates the base path literal. New: deviceBlockUrl (OpsEventsSection previously hand-built /admin/security/device-block against the registry rule), route() to compile-check dynamic-section literals (nav configs, section index redirects), trustedRoute() as the single named seam for runtime-data hrefs (backend-emitted notification deep links, persisted post-login return URL). - Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route hrefs — every sidebar/navbar entry is compile-checked. - ClientRedirect is generic so dynamic-section literals infer. Verified: tsc clean; negative test (typo'd static + dynamic routes) fails compilation as intended; 611/611 vitest; static export builds with an unchanged route list. Co-Authored-By: Claude Fable 5 --- .../app/admin/AdminPageSections.tsx | 31 ++++---- .../app/admin/components/OpsEventsSection.tsx | 7 +- .../app/dashboard/components/SessionTable.tsx | 3 +- .../app/fleet-health/page.tsx | 3 +- .../components/ClientRedirect.tsx | 3 +- .../components/LegacyPathRedirect.tsx | 3 +- .../components/Navbar.tsx | 9 ++- .../components/landing/AuthGate.tsx | 6 +- .../contexts/SidebarContext.tsx | 3 +- .../lib/globalNavConfig.tsx | 78 ++++++++++--------- src/Web/autopilot-monitor-web/lib/routes.ts | 53 ++++++++++--- src/Web/autopilot-monitor-web/next.config.ts | 4 + 12 files changed, 125 insertions(+), 78 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx index 8cb0c0283..04109aa91 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { usePageSections } from "../../hooks/usePageSections"; import { PageSectionItem } from "../../contexts/SidebarContext"; +import { route } from "../../lib/routes"; import { GearIcon, NoSymbolIcon, @@ -47,29 +48,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) { export function AdminPageSections() { const items: PageSectionItem[] = useMemo(() => [ // Tenants - { id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: }, - { id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" }, + { id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: }, + { id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" }, // Metrics - { id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: }, - { id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" }, - { id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" }, + { id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: }, + { id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" }, + { id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" }, // Reports - { id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: }, - { id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" }, - { id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" }, + { id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: }, + { id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" }, + { id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" }, // Security - { id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: }, - { id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" }, - { id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" }, + { id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: }, + { id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" }, + { id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" }, // Settings - { id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: }, - { id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" }, - { id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" }, - { id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" }, + { id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: }, + { id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" }, + { id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" }, + { id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" }, // Ops (single page) { id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: }, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index c3eacc68c..d74e16297 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { sessionUrl } from "@/lib/routes"; +import { sessionUrl, deviceBlockUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; @@ -558,7 +558,6 @@ export function OpsEventsSection({ const sessionId = extractSessionId(selectedEvent.details); if (!sessionId) return null; const reason = buildAutoReason(selectedEvent.eventType, sessionId); - const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`; return (

@@ -574,14 +573,14 @@ export function OpsEventsSection({ View session setSelectedEvent(null)} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700" > Block this device setSelectedEvent(null)} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800" > diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index c82aaaae6..0f07e5765 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -2,6 +2,7 @@ import { sessionUrl } from "@/lib/routes"; import { useRouter } from "next/navigation"; +import type { Route } from "next"; import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react"; import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; @@ -103,7 +104,7 @@ interface SessionTableProps { /** Builds the row's navigation target. Defaults to `/sessions/{id}`; a cross-tenant viewer overrides it to * append `?tenantId=` so a delegated viewer opens the session in the managed tenant's (read-only) context. * Receives the whole session because the target tenant is per-row (session.tenantId), not derivable from id. */ - sessionLinkTarget?: (session: Session) => string; + sessionLinkTarget?: (session: Session) => Route; } export function SessionTable({ diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index 1c524a548..e97f1f7f8 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useMemo } from "react"; import Link from "next/link"; +import type { Route } from "next"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useSignalR } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; @@ -192,7 +193,7 @@ export default function FleetHealthPage() { // model. The model key is "{Manufacturer} {Model}", which the dashboard search matches // against its combined manufacturer+model text. Carry the selected tenant so a global // admin scoped to one tenant lands on that tenant's list rather than their default scope. - const dashboardModelHref = (model: string) => { + const dashboardModelHref = (model: string): Route => { const params = new URLSearchParams({ status: "Failed", search: model }); if (isGlobalAdmin && selectedTenantId) params.set("tenant", selectedTenantId); return `/dashboard?${params.toString()}`; diff --git a/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx b/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx index ea0dba19e..66313c982 100644 --- a/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx +++ b/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx @@ -2,13 +2,14 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; +import type { Route } from "next"; /** * Client-side replacement for server `redirect()` index pages — those are not * supported under `output: 'export'`. Renders nothing and replaces the history * entry on mount (same UX as the role-conditional shell in app/settings/page.tsx). */ -export function ClientRedirect({ to }: { to: string }) { +export function ClientRedirect({ to }: { to: Route }) { const router = useRouter(); useEffect(() => { router.replace(to); diff --git a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx index f78927579..45b82d895 100644 --- a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx +++ b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; +import type { Route } from "next"; import { appDetailUrl, backupUrl, @@ -27,7 +28,7 @@ import { * the target shapes. */ -function legacyTarget(pathname: string, search: URLSearchParams, hash: string): string | null { +function legacyTarget(pathname: string, search: URLSearchParams, hash: string): Route | null { const seg = pathname.replace(/\/+$/, "").split("/").filter(Boolean).map(decodeURIComponent); // /sessions/{id}/inspector diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index ff15ee4e3..393cec598 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -7,6 +7,7 @@ import { useTenantNotifications } from '@/contexts/TenantNotificationContext'; import { useTheme } from '@/contexts/ThemeContext'; import { useState, useRef, useEffect } from 'react'; import Link from 'next/link'; +import { trustedRoute } from '@/lib/routes'; import { usePathname, useRouter } from 'next/navigation'; import { BrandMark } from './BrandMark'; import { trackEvent } from '@/lib/appInsights'; @@ -138,7 +139,7 @@ export default function Navbar() { // so it doesn't linger over the target page. const openNotificationHref = (href: string) => { setShowNotifications(false); - router.push(href); + router.push(trustedRoute(href)); }; const getUserInitials = () => { @@ -330,7 +331,7 @@ export default function Navbar() {

{formatTime(new Date(tn.createdAt))}

{tn.href && ( { e.stopPropagation(); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" @@ -367,7 +368,7 @@ export default function Navbar() {

{formatTime(new Date(gn.createdAt))}

{gn.href && ( { e.stopPropagation(); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" @@ -401,7 +402,7 @@ export default function Navbar() {

{formatTime(notification.timestamp)}

{notification.href && ( { e.stopPropagation(); markAsRead(notification.id); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index a195522d6..207917a4b 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -3,6 +3,8 @@ import { useAuth } from "../../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import type { Route } from "next"; +import { trustedRoute } from "../../lib/routes"; import { consumePostLoginReturnUrl } from "../../lib/postLoginReturn"; import { PORTAL_HOST, shouldCrossOriginToPortal } from "../../lib/hostRouting"; @@ -20,12 +22,12 @@ export function AuthGate() { // Always consume (read + clear) so a stale deep link can't misroute a later // sign-in; only honor it when the user isn't preview-gated. const returnUrl = consumePostLoginReturnUrl(); - let target: string; + let target: Route; if (isPreviewBlocked) { target = "/preview"; } else if (returnUrl) { // Restore the deep link the user originally opened before re-auth. - target = returnUrl; + target = trustedRoute(returnUrl); } else if (user.isDelegated && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== 'Operator') { // A delegated ("MSP") admin with no own-tenant/platform role manages a fleet → land on /fleet. target = "/fleet"; diff --git a/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx b/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx index 7e9a539bf..f1c979269 100644 --- a/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx @@ -1,13 +1,14 @@ "use client"; import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import type { Route } from "next"; import { useSidebarState, CollapseState } from "../hooks/useSidebarState"; export interface PageSectionItem { id: string; label: string; icon?: ReactNode; - href?: string; + href?: Route; /** Optional group name — items sharing the same group are rendered under a collapsible header */ group?: string; /** Icon for the group header (only needs to be set on the first item of a group) */ diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index d553d5ae9..6349c12d9 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -12,6 +12,8 @@ import { FolderIcon, WrenchScrewdriverIcon, } from "./sidebarIcons"; +import type { Route } from "next"; +import { route } from "./routes"; // --- Icons defined inline (not in sidebarIcons) --- @@ -84,7 +86,7 @@ function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) { export interface NavItem { id: string; label: string; - href: string; + href: Route; icon: React.ReactNode; } @@ -92,7 +94,7 @@ export interface NavItem { export interface ExpandableSubItem { id: string; label: string; - href: string; + href: Route; } /** An expandable group with icon + chevron, containing sub-items */ @@ -201,38 +203,38 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ { id: "cfg-tenant", label: "Tenant", icon: , items: [ - { id: "cfg-access-mgmt", label: "Access Management", href: "/settings/tenant/access-management" }, - { id: "cfg-autopilot", label: "Autopilot Validation", href: "/settings/tenant/autopilot" }, - { id: "cfg-hardware", label: "Hardware Whitelist", href: "/settings/tenant/hardware-whitelist" }, - { id: "cfg-notifications", label: "Notifications", href: "/settings/tenant/notifications" }, - { id: "cfg-sla-targets", label: "SLA Targets", href: "/settings/tenant/sla-targets" }, - { id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: "/settings/tenant/bootstrap-sessions" }, - { id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: "/settings/tenant/graph-permissions" }, - { id: "cfg-support", label: "Submit Logs", href: "/settings/tenant/support" }, - { id: "cfg-plan", label: "Plan", href: "/settings/tenant/plan" }, - { id: "cfg-contact", label: "Contact", href: "/settings/tenant/contact" }, + { id: "cfg-access-mgmt", label: "Access Management", href: route("/settings/tenant/access-management") }, + { id: "cfg-autopilot", label: "Autopilot Validation", href: route("/settings/tenant/autopilot") }, + { id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") }, + { id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") }, + { id: "cfg-sla-targets", label: "SLA Targets", href: route("/settings/tenant/sla-targets") }, + { id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: route("/settings/tenant/bootstrap-sessions") }, + { id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: route("/settings/tenant/graph-permissions") }, + { id: "cfg-support", label: "Submit Logs", href: route("/settings/tenant/support") }, + { id: "cfg-plan", label: "Plan", href: route("/settings/tenant/plan") }, + { id: "cfg-contact", label: "Contact", href: route("/settings/tenant/contact") }, ], }, { id: "cfg-agent", label: "Agent", icon: , items: [ - { id: "cfg-agent-settings", label: "Agent Settings", href: "/settings/agent/settings" }, - { id: "cfg-agent-analyzers", label: "Agent Analyzers", href: "/settings/agent/analyzers" }, - { id: "cfg-diagnostics", label: "Diagnostics Package", href: "/settings/agent/diagnostics" }, - { id: "cfg-agent-unrestricted", label: "Unrestricted Mode", href: "/settings/agent/unrestricted-mode" }, + { id: "cfg-agent-settings", label: "Agent Settings", href: route("/settings/agent/settings") }, + { id: "cfg-agent-analyzers", label: "Agent Analyzers", href: route("/settings/agent/analyzers") }, + { id: "cfg-diagnostics", label: "Diagnostics Package", href: route("/settings/agent/diagnostics") }, + { id: "cfg-agent-unrestricted", label: "Unrestricted Mode", href: route("/settings/agent/unrestricted-mode") }, ], }, { id: "cfg-maintenance", label: "Maintenance", icon: , items: [ - { id: "cfg-data", label: "Data Management", href: "/settings/management/data" }, - { id: "cfg-offboarding", label: "Offboarding", href: "/settings/management/offboarding" }, + { id: "cfg-data", label: "Data Management", href: route("/settings/management/data") }, + { id: "cfg-offboarding", label: "Offboarding", href: route("/settings/management/offboarding") }, ], }, { id: "cfg-reporting", label: "Reporting", icon: , items: [ - { id: "cfg-mcp-usage", label: "MCP Usage", href: "/settings/reporting/mcp-usage" }, + { id: "cfg-mcp-usage", label: "MCP Usage", href: route("/settings/reporting/mcp-usage") }, ], }, ], @@ -246,34 +248,34 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ { id: "ga-tenants", label: "Tenants", icon: , items: [ - { id: "ga-tenant-mgmt", label: "Tenant Management", href: "/admin/tenants/management" }, - { id: "ga-config-report", label: "Config Report", href: "/admin/tenants/config-report" }, + { id: "ga-tenant-mgmt", label: "Tenant Management", href: route("/admin/tenants/management") }, + { id: "ga-config-report", label: "Config Report", href: route("/admin/tenants/config-report") }, ], }, { id: "ga-metrics", label: "Metrics", icon: , items: [ - { id: "ga-platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics" }, - { id: "ga-usage", label: "Platform Usage", href: "/admin/metrics/usage" }, + { id: "ga-platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics") }, + { id: "ga-usage", label: "Platform Usage", href: route("/admin/metrics/usage") }, { id: "ga-active-users", label: "Active Users", href: "/admin/presence" }, - { id: "ga-mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage" }, + { id: "ga-mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage") }, ], }, { id: "ga-reports", label: "Reports", icon: , items: [ - { id: "ga-session-reports", label: "Session Reports", href: "/admin/reports/session-reports" }, - { id: "ga-distress-reports", label: "Distress Reports", href: "/admin/reports/distress-reports" }, - { id: "ga-user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback" }, - { id: "ga-session-export", label: "Session Export", href: "/admin/reports/session-export" }, + { id: "ga-session-reports", label: "Session Reports", href: route("/admin/reports/session-reports") }, + { id: "ga-distress-reports", label: "Distress Reports", href: route("/admin/reports/distress-reports") }, + { id: "ga-user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback") }, + { id: "ga-session-export", label: "Session Export", href: route("/admin/reports/session-export") }, ], }, { id: "ga-security", label: "Security", icon: , items: [ - { id: "ga-device-block", label: "Device Block", href: "/admin/security/device-block" }, - { id: "ga-version-block", label: "Version Block", href: "/admin/security/version-block" }, - { id: "ga-vulnerability", label: "Vulnerability Data", href: "/admin/security/vulnerability-data" }, + { id: "ga-device-block", label: "Device Block", href: route("/admin/security/device-block") }, + { id: "ga-version-block", label: "Version Block", href: route("/admin/security/version-block") }, + { id: "ga-vulnerability", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data") }, ], }, { @@ -281,13 +283,13 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ // are mutation surfaces; the reader gets redacted/read-only tenant config elsewhere). id: "ga-settings", label: "Settings", icon: , visibility: "globalAdminOnly", items: [ - { id: "ga-global", label: "Global Settings", href: "/admin/settings/global" }, - { id: "ga-diag-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths" }, - { id: "ga-mcp-users", label: "MCP Users", href: "/admin/settings/mcp-users" }, - { id: "ga-delegated-admins", label: "Delegated Admins", href: "/admin/settings/delegated-admins" }, - { id: "ga-tenant-groups", label: "Tenant Groups", href: "/admin/settings/tenant-groups" }, - { id: "ga-config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed" }, - { id: "ga-usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans" }, + { id: "ga-global", label: "Global Settings", href: route("/admin/settings/global") }, + { id: "ga-diag-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths") }, + { id: "ga-mcp-users", label: "MCP Users", href: route("/admin/settings/mcp-users") }, + { id: "ga-delegated-admins", label: "Delegated Admins", href: route("/admin/settings/delegated-admins") }, + { id: "ga-tenant-groups", label: "Tenant Groups", href: route("/admin/settings/tenant-groups") }, + { id: "ga-config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed") }, + { id: "ga-usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans") }, ], }, { diff --git a/src/Web/autopilot-monitor-web/lib/routes.ts b/src/Web/autopilot-monitor-web/lib/routes.ts index 1b008bbc5..183a54520 100644 --- a/src/Web/autopilot-monitor-web/lib/routes.ts +++ b/src/Web/autopilot-monitor-web/lib/routes.ts @@ -12,38 +12,41 @@ * Rule: the fragment (#...) always comes AFTER the query string. */ -function withQuery( - path: string, +import type { Route } from "next"; + +function withQuery( + path: Route, params: Record, hash?: string, -): string { +): Route { const qs = Object.entries(params) .filter(([, v]) => v !== undefined && v !== "") .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`) .join("&"); const fragment = hash ? (hash.startsWith("#") ? hash : `#${hash}`) : ""; - return `${path}${qs ? `?${qs}` : ""}${fragment}`; + // Appending a query/fragment to an already-validated Route keeps it valid. + return `${path}${qs ? `?${qs}` : ""}${fragment}` as Route; } export function sessionUrl( sessionId: string, opts?: { tenantId?: string; hash?: string }, -): string { +): Route { return withQuery("/sessions", { id: sessionId, tenantId: opts?.tenantId }, opts?.hash); } -export function inspectorUrl(sessionId: string, opts?: { tab?: string }): string { +export function inspectorUrl(sessionId: string, opts?: { tab?: string }): Route { return withQuery("/sessions/inspector", { id: sessionId, tab: opts?.tab }); } -export function diagnosisUrl(sessionId: string): string { +export function diagnosisUrl(sessionId: string): Route { return withQuery("/diagnosis", { id: sessionId }); } export function appDetailUrl( appName: string, opts?: { days?: string; tenantId?: string }, -): string { +): Route { return withQuery("/apps/detail", { name: appName, days: opts?.days, @@ -51,13 +54,43 @@ export function appDetailUrl( }); } -export function backupUrl(backupId: string): string { +export function backupUrl(backupId: string): Route { return withQuery("/admin/backups/detail", { id: backupId }); } -export function customsArchiveUrl(tenantId: string, historyRowKey: string): string { +export function deviceBlockUrl( + sessionId: string, + reason: string, + action?: "Block" | "Kill", +): Route { + return withQuery("/admin/security/device-block", { sessionId, reason, action }); +} + +export function customsArchiveUrl(tenantId: string, historyRowKey: string): Route { return withQuery("/admin/customs-archive/detail", { tenantId, rowKey: historyRowKey, }); } + +/** + * Compile-time validates a route literal that targets a DYNAMIC route (e.g. + * `/admin/settings/[section]`): the bare `Route` type only covers static + * routes, so dynamic-section literals — nav configs, section index redirects — + * go through this identity helper, where inference of `T` runs the literal + * against the generated route union. A typo'd prefix is a compile error. + */ +export function route(href: Route): Route { + return href as Route; +} + +/** + * Brands an in-app href that only exists at runtime (backend-emitted + * notification deep links, the persisted post-login return URL) for the typed + * router APIs. The compiler cannot validate data, only literals — producers of + * these hrefs are responsible for emitting canonical shapes (ideally via the + * builders above). Keep every such cast behind this single seam. + */ +export function trustedRoute(href: string): Route { + return href as Route; +} diff --git a/src/Web/autopilot-monitor-web/next.config.ts b/src/Web/autopilot-monitor-web/next.config.ts index 3c4768643..97aa232ad 100644 --- a/src/Web/autopilot-monitor-web/next.config.ts +++ b/src/Web/autopilot-monitor-web/next.config.ts @@ -25,6 +25,10 @@ const nextConfig: NextConfig = { // all client state (e.g. the sidebar collapses on each click). ...(isDev ? { skipTrailingSlashRedirect: true } : {}), reactStrictMode: true, + // Statically typed links: `next typegen` derives a Route union from the app + // tree, so a broken internal /router.push target is a compile error — + // the failure class behind the recent RSC/routing incidents. + typedRoutes: true, experimental: { // Rewrite these heavy packages to per-module imports so unused exports are // tree-shaken out of the route chunks that touch them. From 444d50a0648c51d8f240a1e8aced95c87e92034b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 31 Jul 2026 00:47:05 +0200 Subject: [PATCH 2/2] chore(web): lint findings to zero errors + CI lint gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package deal — fixes only land together with the gate, otherwise they drift right back. CI web job now runs `eslint . --max-warnings 163`: errors fail outright, the warning cap is a ratchet (lower it when fixing warnings, never raise it). Fixed to zero (mechanical categories, behavior unchanged): - no-unused-vars (49): dead imports/locals removed; where a prop is part of a live call-site contract, only the destructured binding dropped - no-explicit-any (29): real structural types for event payloads (EnrollmentEvent.data et al -> Record + per-event interfaces), SignalR passthroughs typed via the library's own signatures; five consumer files got typing-only follow-through - no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes - no-require-imports (5): scripts/**/*.js override — Node CJS scripts, require() is correct there - one stale eslint-disable directive removed Deliberately NOT fixed inline: the React-Compiler hooks rules new in eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26, static-components 14, purity 6, immutability 5, preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs — each is a per-site behavioral refactor in auth/SignalR-adjacent code, to be burned down incrementally under the ratchet. Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc clean, vitest 611/611, static export builds. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 7 ++ .../app/admin/AdminPageSections.tsx | 10 --- .../components/RestoreRowDiffModal.tsx | 2 +- .../admin/components/OpsAlertRulesSection.tsx | 8 --- .../components/TenantManagementSection.tsx | 2 +- .../metrics/sections/SectionAgentMetrics.tsx | 7 +- .../components/RestoreBrowserTab.tsx | 2 +- .../app/admin/ops/session-cleanup/page.tsx | 1 - .../admin/reports/[section]/SectionClient.tsx | 2 +- .../security/[section]/SectionClient.tsx | 2 +- .../sections/SectionTenantConfigReport.tsx | 2 - .../components/TemplateConfigModal.tsx | 2 +- .../app/analyze-rules/page.tsx | 4 -- .../app/apps/detail/page.tsx | 5 -- .../autopilot-monitor-web/app/audit/page.tsx | 3 - .../app/dashboard/components/SessionTable.tsx | 1 - .../dashboard/components/WelcomeMessage.tsx | 1 - .../app/dashboard/page.tsx | 5 +- .../app/diagnosis/page.tsx | 6 +- .../components/GatherRuleCard.tsx | 4 +- .../components/GatherRuleFormFields.tsx | 2 +- .../app/gather-rules/page.tsx | 6 +- .../app/geographic-performance/page.tsx | 2 +- .../geographic-performance/sessions/page.tsx | 1 - .../app/global-error.tsx | 1 - .../app/health-check/page.tsx | 6 +- .../app/ime-log-patterns/page.tsx | 3 - .../progress/hooks/useProgressDerivedData.ts | 34 +++++++-- .../components/AnalysisResultsSection.tsx | 6 +- .../sessions/components/DeviceDetailsCard.tsx | 71 ++++++++++++------- .../app/sessions/components/EventTimeline.tsx | 27 ++++--- .../sessions/components/MarkFailedModal.tsx | 2 +- .../sessions/components/OobeConfigModal.tsx | 1 - .../app/sessions/components/PhaseTimeline.tsx | 7 +- .../components/VulnerabilityReportSection.tsx | 5 +- .../sessions/hooks/useSessionDerivedData.ts | 3 +- .../app/sessions/inspector/page.tsx | 2 +- .../app/sessions/utils/eventHelpers.ts | 4 +- .../components/DataManagementSection.tsx | 4 +- .../components/DiagnosticsSection.tsx | 6 +- .../settings/components/McpUsersSection.tsx | 2 +- .../SectionOptionalGraphCapabilities.tsx | 4 +- .../autopilot-monitor-web/app/sla/page.tsx | 2 +- .../app/usage-metrics/page.tsx | 3 - .../components/DownloadProgress.tsx | 39 +++++++++- .../components/GlobalSidebar.tsx | 4 +- .../components/PerformanceChart.tsx | 29 +++++++- .../components/rules/FormJsonToggle.tsx | 1 - .../contexts/SignalRContext.tsx | 18 +++-- .../autopilot-monitor-web/eslint.config.mjs | 28 +++++++- .../lib/__tests__/installProgress.test.ts | 2 +- .../lib/__tests__/scriptExecutions.test.ts | 2 +- .../lib/globalNavConfig.tsx | 4 -- .../lib/historicReplay.ts | 2 +- .../lib/installProgress.ts | 36 +++++++++- .../lib/scriptDisplayNames.ts | 1 - .../lib/scriptExecutions.ts | 26 ++++++- .../autopilot-monitor-web/types/enrollment.ts | 4 +- 58 files changed, 304 insertions(+), 172 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68875a835..e77495ba5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,13 @@ jobs: - name: Typecheck run: npx tsc --noEmit + # Errors fail outright. Warnings are ratcheted: the cap is the current + # count of accepted warnings (React-Compiler hooks-rule findings demoted + # in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower + # the cap to match; never raise it. + - name: Lint + run: npm run lint -- --max-warnings 163 + - name: Tests run: npm test diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx index 04109aa91..d6076bcec 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx @@ -6,22 +6,12 @@ import { PageSectionItem } from "../../contexts/SidebarContext"; import { route } from "../../lib/routes"; import { GearIcon, - NoSymbolIcon, DocumentTextIcon, ChartBarIcon, BuildingOfficeIcon, ShieldCheckIcon, } from "../../lib/sidebarIcons"; -// Inline icon (same as MetricsSidebar had) -function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) { - return ( - - - - ); -} - // Inline icon for Ops (wrench) function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) { return ( diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx index 3207c10ae..117228966 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx @@ -90,7 +90,7 @@ export function RestoreRowDiffModal({ This is an authentication / authorization table (GlobalAdmins, TenantAdmins,{" "} McpUsers). Restoring this row will overwrite the live{" "} - IsEnabled flag — confirm that the backup row's enable/disable + IsEnabled flag — confirm that the backup row's enable/disable state is what you intend.
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index bcff312c0..ff6b2ed1d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -232,14 +232,6 @@ export function OpsAlertRulesSection({ const enabledRulesCount = rules.filter(r => r.enabled).length; const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length; - // Get category for a given event type - const getCategoryForEvent = (eventType: string): string => { - for (const [cat, types] of Object.entries(OPS_EVENT_TYPES)) { - if (types.includes(eventType)) return cat; - } - return "Unknown"; - }; - return (
{/* Alert Rules */} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index bfd2c0fb3..fa215dc70 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx index d7c4e47e5..e1ae5b582 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useState, useMemo, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; import TruncatedLabel from '@/components/TruncatedLabel'; import { useAuth } from '../../../../contexts/AuthContext'; @@ -138,9 +137,7 @@ function pN(values: number[], percentile: number): number { // ── Component ────────────────────────────────────────────────────────────────── export function SectionAgentMetrics() { - const router = useRouter(); - - const { getAccessToken, user } = useAuth(); + const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); const [loading, setLoading] = useState(true); @@ -845,8 +842,6 @@ function StatCard({ label, value, detail, color }: { label: string; value: strin function FootprintBadge({ value, thresholds, - unit, - formatFn, }: { value: number; thresholds: [number, number, number]; 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 ef2a4a695..630689201 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 @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAdminConfig } from "../../../AdminConfigContext"; diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx index 4ecc1dcac..5a5976063 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx @@ -139,7 +139,6 @@ function TabButton({ function InFlightTab({ getAccessToken, setError, - setSuccessMessage, }: { getAccessToken: (forceRefresh?: boolean) => Promise; setError: (error: string | null) => void; diff --git a/src/Web/autopilot-monitor-web/app/admin/reports/[section]/SectionClient.tsx b/src/Web/autopilot-monitor-web/app/admin/reports/[section]/SectionClient.tsx index 3d77561a9..ef5d54005 100644 --- a/src/Web/autopilot-monitor-web/app/admin/reports/[section]/SectionClient.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/reports/[section]/SectionClient.tsx @@ -1,7 +1,7 @@ "use client"; import { notFound } from "next/navigation"; -import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections"; +import { type ReportsSectionId } from "../reportsNavSections"; import { SectionSessionReports } from "../sections/SectionSessionReports"; import { SectionDistressReports } from "../sections/SectionDistressReports"; import { SectionUserFeedback } from "../sections/SectionUserFeedback"; diff --git a/src/Web/autopilot-monitor-web/app/admin/security/[section]/SectionClient.tsx b/src/Web/autopilot-monitor-web/app/admin/security/[section]/SectionClient.tsx index 32979c068..7e7b50f96 100644 --- a/src/Web/autopilot-monitor-web/app/admin/security/[section]/SectionClient.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/security/[section]/SectionClient.tsx @@ -1,7 +1,7 @@ "use client"; import { notFound } from "next/navigation"; -import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections"; +import { type SecuritySectionId } from "../securityNavSections"; import { SectionDeviceBlock } from "../sections/SectionDeviceBlock"; import { SectionVersionBlock } from "../sections/SectionVersionBlock"; import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData"; diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 7a90cc4c6..d02c1f38d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -426,8 +426,6 @@ export function SectionTenantConfigReport() { })() : []; - const selectedTenant = tenants.find((t) => t.tenantId === selectedTenantId); - return (
{/* Header */} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/TemplateConfigModal.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/TemplateConfigModal.tsx index e291fb4aa..cef0db334 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/TemplateConfigModal.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/TemplateConfigModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { AnalyzeRule, TemplateVariable } from "../types"; +import { AnalyzeRule } from "../types"; interface TemplateConfigModalProps { rule: AnalyzeRule; diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 88d4a3773..538cde34f 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; @@ -25,8 +23,6 @@ import TemplateConfigModal from "./components/TemplateConfigModal"; import { DOCS_URL } from "@/utils/config"; export default function AnalyzeRulesPage() { - const router = useRouter(); - const { user, getAccessToken } = useAuth(); const { successMessage, error, showSuccess, showError } = useNotificationMessages(); diff --git a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx index 43c8d16e1..a274b2c5d 100644 --- a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx @@ -376,11 +376,6 @@ function AppDetailContent() { return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`; } - const failureCodeBarColor = (row: Record): string => { - const c = Number(row.count); - return c >= 5 ? chartColors.danger : c >= 2 ? chartColors.warning : chartColors.muted; - }; - return (
diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx index 6d2d751e3..75bbc92dc 100644 --- a/src/Web/autopilot-monitor-web/app/audit/page.tsx +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -2,7 +2,6 @@ import { Fragment, useCallback, useEffect, useState } from 'react'; import { TableSkeleton } from '@/components/skeletons/TableSkeleton'; -import { useRouter } from 'next/navigation'; import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; @@ -63,8 +62,6 @@ function dateInputToIsoEnd(value: string): string { } export default function AuditPage() { - const router = useRouter(); - const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index 0f07e5765..c684b8ca9 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -761,7 +761,6 @@ export function SessionTable({ function SessionCell({ columnKey, session, - adminMode, globalAdminMode, blockedDevicesSet, user, diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/WelcomeMessage.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/WelcomeMessage.tsx index e76cd60cd..b3ed6983d 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/WelcomeMessage.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/WelcomeMessage.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { DOCS_URL } from "@/utils/config"; export function WelcomeMessage() { diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 8cb42912f..6614657a0 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -90,9 +90,8 @@ function HomeContent() { const mainClassName = fullWidth ? "w-full px-4 sm:px-6 lg:px-8 py-4" : "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8"; - const { user, logout, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth(); + const { user, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth(); const { addNotification } = useNotifications(); - const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked"); // `?tenant=` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill // a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there). const initialTenantFilter = searchParams?.get("tenant") ?? ""; @@ -109,7 +108,7 @@ function HomeContent() { // Drives the stats refetch — server-side stats follow the submitted scope so // typing into the filter input doesn't trigger a backend round-trip per keystroke. const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState(initialTenantFilter); - const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode(); + const { adminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode(); const signalR = useSignalR(); const { tenantId } = useTenant(); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index a52031bd8..f9833f295 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -802,12 +802,14 @@ function DiagnosisContent() { ); } -function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record; sessionId: string }) { +function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record; sessionId: string }) { const eventLinks: { signal: string; eventId: string; eventType?: string }[] = []; const seenEventIds = new Set(); - for (const [signal, evidence] of Object.entries(matchedConditions)) { + for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) { if (signal.startsWith("factor_")) continue; + // Evidence values are rule-engine JSON; only object-shaped entries carry event links. + const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null; if (evidence && typeof evidence === "object" && evidence.eventId) { // Multiple matched conditions can extract different fields from the // same event — one chip per distinct event, not per condition. diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx index 4743b6208..86f39e404 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, EMPTY_FORM, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types"; +import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types"; import { GatherRuleFormFields } from "./GatherRuleFormFields"; import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle"; import { validateGatherRuleTarget } from "@/utils/guardValidation"; @@ -455,7 +455,7 @@ export function GatherRuleCard({ try { const parsed = JSON.parse(jsonText) as NewRuleForm; onSaveEdit(rule, withDerivedScopeMode({ ...editForm, ...parsed })); - } catch (e) { + } catch { // jsonError is handled by parent } } else { diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx index 3df9bd032..a6510c13c 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx @@ -363,7 +363,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo />

XPath query to extract values. Examples: /root/element (path),{" "} - //element (anywhere),{" "} + {"//element"} (anywhere),{" "} /root/item[@attr='value'] (filter),{" "} /root/element/text() (text content).

diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx index eb7a03016..4f1ee5b8e 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; @@ -16,14 +14,12 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; -import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types"; +import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types"; import { GatherRuleFormFields } from "./components/GatherRuleFormFields"; import { GatherRuleCard } from "./components/GatherRuleCard"; import { DOCS_URL } from "@/utils/config"; export default function GatherRulesPage() { - const router = useRouter(); - const { user, getAccessToken } = useAuth(); const { successMessage, error, showSuccess, showError } = useNotificationMessages(); diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index 9dcdb5767..79d180d5b 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -144,7 +144,7 @@ export default function GeographicPerformancePage() { // Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants), // and scope flags. Default selection is the GA's own tenant. const scope = useAggregatedAdminScope(); - const { isGlobalAdmin, routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope; + const { routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope; const progress = useFetchProgress("geoPerf.lastFetchMs"); const { begin: progressBegin, finish: progressFinish } = progress; diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx index f6f5ef9ca..c7f8002da 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx @@ -223,7 +223,6 @@ function LocationSessionsContent() {