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:
NuklearRabbit
2026-08-02 06:41:56 +02:00
parent 4bc3e33953
commit 4437b8792a
11 changed files with 596 additions and 46 deletions
+34
View File
@@ -180,6 +180,18 @@ export interface ScanResult {
created: Record<string, number>;
}
export interface SearchResultItem {
type: "vehicle" | "booking" | "data_quality_issue" | "section";
label: string;
detail: string;
link: string;
}
export interface SearchResponse {
query: string;
results: SearchResultItem[];
}
export interface MergeCustomersRequest {
survivor_ref: string;
field_overrides?: Record<string, string>;
@@ -218,6 +230,28 @@ export interface KnowledgeHealth {
document_count: number;
}
export interface N8nIntegrationStatus {
configured: boolean;
dispatch_enabled: boolean;
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
pending: number;
delivering: number;
failed: number;
succeeded: number;
latest_success_at: string | null;
latest_failure_at: string | null;
}
export interface McpHubIntegrationStatus {
registration_enabled: boolean;
state: "not_configured" | "configured";
}
export interface IntegrationStatus {
n8n: N8nIntegrationStatus;
mcp_hub: McpHubIntegrationStatus;
}
export interface AuditEvent {
id: string;
actor_type: string;
+152 -42
View File
@@ -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 && (
+13 -1
View File
@@ -70,15 +70,27 @@ a:hover { color: var(--teal); }
.sidebar-foot div { display: grid; gap: 2px; }
.sidebar-foot strong { font-size: .72rem; color: #dbe5ef; }
.sidebar-foot span:not(.environment-dot) { font-size: .66rem; color: #7f8da1; }
.sidebar-reset { margin: 0 12px 12px; display: grid; gap: 8px; }
.sidebar-reset .button { width: 100%; }
.sidebar-reset .confirm-bar { background: var(--surface); border-radius: var(--radius); padding: 10px; display: grid; gap: 8px; }
.sidebar-reset .confirm-bar p { margin: 0; font-size: .7rem; color: var(--ink-soft); }
.environment-dot, .live-indicator { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; box-shadow: 0 0 0 4px rgba(45, 212, 191, .12); }
.app-workspace { grid-column: 2; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { height: 64px; display: flex; align-items: center; gap: 20px; padding: 0 28px; background: rgba(255,255,255,.97); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.global-search { width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
.global-search { position: relative; width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
.global-search svg { width: 16px; }
.global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; }
.global-search:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(18, 132, 126, .14); }
.global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; }
.search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 30; max-height: 60vh; overflow-y: auto; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); padding: 6px; }
.search-status { margin: 0; padding: 10px 8px; font-size: .78rem; color: var(--muted); }
.search-result { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; border: 0; border-radius: var(--radius); background: transparent; color: var(--ink); text-align: left; cursor: pointer; font: inherit; }
.search-result svg { width: 16px; flex-shrink: 0; color: var(--muted); }
.search-result:hover, .search-result.is-active { background: var(--surface-subtle); }
.search-result-copy { display: flex; flex-direction: column; min-width: 0; }
.search-result-copy strong { font-size: .82rem; }
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; }
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
.timezone svg { width: 15px; }