feat(search): add role-aware backend search and truthful n8n status
Two new endpoints. GET /api/v1/search returns bounded typed results (vehicle, booking, data-quality-issue, application section) instead of the frontend guessing routes from regex patterns against public-ref prefixes; data-quality and manager-only sections are filtered server-side by role, and customers are deliberately never returned since no customer detail route exists in this PoC. GET /api/v1/integrations/status aggregates outbox delivery counts (pending/delivering/succeeded/failed) into a single truthful n8n state (disabled/unavailable/degraded/operational/no_evidence) instead of the UI showing whichever status the single most recent event happened to be in -- a vehicle_status_conflict-style bug where one stale failure or one lucky success could misreport the dispatcher's actual health. Also fixes a real config gap this surfaced: MCP_HUB_REGISTRATION_ENABLED was documented in .env.example but had no corresponding Settings field, so it was silently ignored by pydantic-settings' extra="ignore" and never actually read anywhere in the codebase.
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role } from "../api/types";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
booking: "bookings",
|
||||
data_quality_issue: "quality",
|
||||
section: "chevron",
|
||||
};
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -32,23 +40,21 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
|
||||
},
|
||||
];
|
||||
|
||||
const SEARCH_DESTINATIONS = [
|
||||
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
|
||||
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
|
||||
{ to: "/bookings", terms: ["booking", "bookings", "rental"] },
|
||||
{ to: "/data-quality", terms: ["quality", "data quality", "issues"] },
|
||||
{ to: "/knowledge", terms: ["knowledge", "procedures"] },
|
||||
{ to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] },
|
||||
{ to: "/audit", terms: ["audit", "history"] },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [resetConfirming, setResetConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const searchBox = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
@@ -72,38 +78,88 @@ export function Layout() {
|
||||
return () => window.removeEventListener("keydown", focusGlobalSearch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
setSearchLoading(false);
|
||||
setSearchError(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
setSearchError(false);
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ query: string; results: SearchResultItem[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
||||
)
|
||||
.then((response) => {
|
||||
setSearchResults(response.results);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.catch(() => setSearchError(true))
|
||||
.finally(() => setSearchLoading(false));
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchStatus("Enter a section or a vehicle, booking or issue reference.");
|
||||
async function handleDemoReset() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
// The server invalidates the acting session as part of reset; drop local state the
|
||||
// same way an explicit logout would and return to the login screen.
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : "Could not reset demo data.");
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(item: SearchResultItem) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
navigate(item.link);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
const publicRef = query.toUpperCase();
|
||||
let destination: string | undefined;
|
||||
|
||||
if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`;
|
||||
else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`;
|
||||
else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`;
|
||||
else {
|
||||
const normalized = query.toLowerCase();
|
||||
destination = SEARCH_DESTINATIONS.find(({ terms }) =>
|
||||
terms.some((term) => term.includes(normalized) || normalized.includes(term)),
|
||||
)?.to;
|
||||
if (!searchOpen || searchResults.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % searchResults.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const target = searchResults[activeIndex] ?? searchResults[0];
|
||||
if (target) selectResult(target);
|
||||
}
|
||||
|
||||
if (destination) {
|
||||
setSearchStatus("");
|
||||
navigate(destination);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -136,6 +192,26 @@ export function Layout() {
|
||||
<span className="environment-dot" />
|
||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
Reset demo data
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label="Confirm demo reset">
|
||||
<p>All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.</p>
|
||||
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
||||
{resetting ? "Resetting…" : "Yes, reset"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
|
||||
@@ -145,24 +221,58 @@ export function Layout() {
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<form className="global-search" role="search" onSubmit={handleSearch}>
|
||||
<div className="global-search" role="search" ref={searchBox}>
|
||||
<Icon name="search" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
|
||||
<input
|
||||
id="global-search-input"
|
||||
ref={searchInput}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="global-search-results"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
||||
value={searchQuery}
|
||||
placeholder="Search fleet, booking or section…"
|
||||
aria-describedby="global-search-status"
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
setSearchStatus("");
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<kbd>Ctrl K</kbd>
|
||||
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
|
||||
</form>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="search-results" id="global-search-results" role="listbox">
|
||||
{searchLoading && <p className="search-status">Searching…</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">Search is unavailable right now.</p>}
|
||||
{!searchLoading && !searchError && searchResults.length === 0 && (
|
||||
<p className="search-status">No matches for "{searchQuery.trim()}".</p>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.type}-${item.link}`}
|
||||
id={`search-result-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
type="button"
|
||||
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => selectResult(item)}
|
||||
>
|
||||
<Icon name={SEARCH_ICON[item.type]} />
|
||||
<span className="search-result-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.detail}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
{user && (
|
||||
|
||||
Reference in New Issue
Block a user