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 e2fed855..d127cb22 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -7,6 +7,7 @@ import { trackEvent } from "@/lib/appInsights"; import { fuzzyContains } from "@/utils/fuzzy"; import { buildUniqueValuesByField } from "./uniqueValuesByField"; import { SessionStatusBadge } from "@/components/SessionStatusBadge"; +import { TenantFilterBar } from "./TenantFilterBar"; // Column definition for the session table interface ColumnDef { @@ -150,9 +151,6 @@ export function SessionTable({ const [openFilterColumn, setOpenFilterColumn] = useState(null); const columnSelectorRef = useRef(null); const filterDropdownRef = useRef(null); - const tenantDropdownRef = useRef(null); - const [showTenantSuggestions, setShowTenantSuggestions] = useState(false); - const [tenantSelectedIndex, setTenantSelectedIndex] = useState(-1); const searchDropdownRef = useRef(null); const [showSearchSuggestions, setShowSearchSuggestions] = useState(false); const [searchSelectedIndex, setSearchSelectedIndex] = useState(-1); @@ -184,7 +182,6 @@ export function SessionTable({ // Defer expensive suggestion scans so rapid typing keeps the input responsive. const deferredSearchQuery = useDeferredValue(searchQuery); - const deferredTenantIdFilter = useDeferredValue(tenantIdFilter); const searchSuggestions = useMemo(() => { const q = deferredSearchQuery.trim().toLowerCase(); @@ -231,15 +228,6 @@ export function SessionTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [deferredSearchQuery, sessions]); - // Fuzzy-match tenants by domain name or tenant ID - const tenantSuggestions = useMemo(() => { - const q = deferredTenantIdFilter.trim().toLowerCase(); - if (q.length < 2 || tenantList.length === 0) return []; - return tenantList - .filter((t) => t.domainName.toLowerCase().includes(q) || t.tenantId.toLowerCase().includes(q)) - .slice(0, 8); - }, [deferredTenantIdFilter, tenantList]); - const tenantDomainById = useMemo( () => new Map(tenantList.map((t) => [t.tenantId, t.domainName])), [tenantList], @@ -259,18 +247,15 @@ export function SessionTable({ if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) { setOpenFilterColumn(null); } - if (tenantDropdownRef.current && !tenantDropdownRef.current.contains(e.target as Node)) { - setShowTenantSuggestions(false); - } if (searchDropdownRef.current && !searchDropdownRef.current.contains(e.target as Node)) { setShowSearchSuggestions(false); } } - if (showColumnSelector || openFilterColumn || showTenantSuggestions || showSearchSuggestions) { + if (showColumnSelector || openFilterColumn || showSearchSuggestions) { document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); } - }, [showColumnSelector, openFilterColumn, showTenantSuggestions, showSearchSuggestions]); + }, [showColumnSelector, openFilterColumn, showSearchSuggestions]); // Compute unique values for every filterable field in a single pass over sessions. // Memoized so that header re-renders pay only a map lookup, not N × O(sessions). @@ -425,100 +410,13 @@ export function SessionTable({ {/* Tenant ID Filter (Global Admin only) */} {globalAdminMode && ( -
-
- { - onTenantIdFilterChange(e.target.value); - setShowTenantSuggestions(true); - setTenantSelectedIndex(-1); - }} - onFocus={() => { - if (tenantSuggestions.length > 0) setShowTenantSuggestions(true); - }} - onKeyDown={(e) => { - if (showTenantSuggestions && tenantSuggestions.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setTenantSelectedIndex((i) => Math.min(i + 1, tenantSuggestions.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setTenantSelectedIndex((i) => Math.max(i - 1, -1)); - return; - } - if (e.key === "Enter" && tenantSelectedIndex >= 0) { - e.preventDefault(); - const selected = tenantSuggestions[tenantSelectedIndex]; - onTenantIdFilterChange(selected.tenantId); - setShowTenantSuggestions(false); - setTenantSelectedIndex(-1); - return; - } - if (e.key === "Escape") { - setShowTenantSuggestions(false); - return; - } - } - if (e.key === "Enter") { - setShowTenantSuggestions(false); - onTenantIdFilterSubmit(); - } - }} - className="w-full px-4 py-2 pr-10 border border-purple-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors font-mono text-sm" - /> - {tenantIdFilter && ( - - )} - {/* Tenant suggestions dropdown */} - {showTenantSuggestions && tenantSuggestions.length > 0 && ( -
- {tenantSuggestions.map((t, idx) => ( - - ))} -
- )} -
- -
+ )} {/* Search Input with Fuzzy Suggestions */} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx new file mode 100644 index 00000000..1da7d0a9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useDeferredValue } from "react"; + +interface TenantFilterBarProps { + tenantIdFilter: string; + onChange: (value: string) => void; + onSubmit: () => void; + onClear: () => void; + tenantList: { tenantId: string; domainName: string }[]; +} + +/** + * Cross-tenant (Global Admin / delegated MSP) tenant filter input with fuzzy + * suggestions. Extracted from SessionTable so the dashboard can keep it on + * screen even when a filtered fetch returns zero sessions and the table itself + * is not rendered — otherwise a GA drilling through tenants has no way to + * clear or change the filter without leaving the page. + */ +export function TenantFilterBar({ + tenantIdFilter, + onChange, + onSubmit, + onClear, + tenantList, +}: TenantFilterBarProps) { + const dropdownRef = useRef(null); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + // Defer expensive suggestion scans so rapid typing keeps the input responsive. + const deferredTenantIdFilter = useDeferredValue(tenantIdFilter); + + // Fuzzy-match tenants by domain name or tenant ID + const suggestions = useMemo(() => { + const q = deferredTenantIdFilter.trim().toLowerCase(); + if (q.length < 2 || tenantList.length === 0) return []; + return tenantList + .filter((t) => t.domainName.toLowerCase().includes(q) || t.tenantId.toLowerCase().includes(q)) + .slice(0, 8); + }, [deferredTenantIdFilter, tenantList]); + + // Close suggestions on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowSuggestions(false); + } + } + if (showSuggestions) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showSuggestions]); + + return ( +
+
+ { + onChange(e.target.value); + setShowSuggestions(true); + setSelectedIndex(-1); + }} + onFocus={() => { + if (suggestions.length > 0) setShowSuggestions(true); + }} + onKeyDown={(e) => { + if (showSuggestions && suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, -1)); + return; + } + if (e.key === "Enter" && selectedIndex >= 0) { + e.preventDefault(); + const selected = suggestions[selectedIndex]; + onChange(selected.tenantId); + setShowSuggestions(false); + setSelectedIndex(-1); + return; + } + if (e.key === "Escape") { + setShowSuggestions(false); + return; + } + } + if (e.key === "Enter") { + setShowSuggestions(false); + onSubmit(); + } + }} + className="w-full px-4 py-2 pr-10 border border-purple-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors font-mono text-sm" + /> + {tenantIdFilter && ( + + )} + {/* Tenant suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((t, idx) => ( + + ))} +
+ )} +
+ +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 60eb9fcd..fb0ee4fc 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import { useNotifications } from "../../contexts/NotificationContext"; import { StatsCard } from "./components/StatsCards"; import { WelcomeMessage } from "./components/WelcomeMessage"; import { SessionTable } from "./components/SessionTable"; +import { TenantFilterBar } from "./components/TenantFilterBar"; import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals"; import TipOfTheDay from "./components/TipOfTheDay"; import { useAdminMode } from "@/hooks/useAdminMode"; @@ -249,6 +250,14 @@ function HomeContent() { const applyTenantIdFilter = (value: string) => { setTenantIdFilter(value); + // Emptying the input while a filter is submitted acts as an implicit clear: + // without this, deleting the text only widens the client-side view over the + // already-loaded (filtered) sessions — the backend scope would stay on the + // old tenant until the user hits Filter again on an empty box. + if (!value.trim() && submittedTenantIdFilter.trim()) { + setSubmittedTenantIdFilter(""); + refetchWith(""); + } }; const submitTenantIdFilter = () => { @@ -417,6 +426,27 @@ function HomeContent() { + {/* Zero sessions in a cross-tenant view: keep the tenant filter on screen so a + GA/MSP admin drilling through tenants can clear it or type the next tenant + without leaving the page (the SessionTable — which normally hosts this bar — + is not rendered when the filtered fetch came back empty). */} + {!loading && sessions.length === 0 && crossTenant && ( +
+ + {submittedTenantIdFilter.trim() && ( +

+ No sessions found for this tenant yet. Clear the filter or enter another tenant to continue. +

+ )} +
+ )} + {/* Welcome message - only show when no sessions */} {!loading && sessions.length === 0 && }