fix(deploy): reuse shared server n8n

This commit is contained in:
NuklearRabbit
2026-08-02 03:56:17 +02:00
parent 686b62f592
commit 1f292e14bb
12 changed files with 234 additions and 61 deletions
+24 -15
View File
@@ -6,13 +6,15 @@
`/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
- Deployment config commits: `07ab7a3`, `847cd05`, `e1a1c67`, `1e13943`. The accepted
baseline `4bf9afbeff44088864e0844769d4dd0e4089d85b` remains intact.
- All four services are healthy with zero restarts. Only web port 1236 is exposed to the
LAN; n8n is loopback-only on host port 15678; API and PostgreSQL are internal.
- MobilityOps PostgreSQL, API and web services are healthy. Only web port 1236 is exposed
by the MobilityOps Compose project; API and PostgreSQL remain internal. Automation uses
the server's existing shared n8n at `http://192.168.10.150:5678`; no second MobilityOps
n8n container is running.
- Migrations are at `e7b08389f47f (head)` and deterministic seed counts match final
acceptance. A Chrome smoke test covered every requested page and a real return; its n8n
event succeeded on attempt 1. Browser console and recent service log scans were clean.
- RAGcore is disabled in favor of the honest local demo provider. MCP Hub registration is
disabled. n8n is initialized, published, healthy, and live-verified.
disabled. The MobilityOps workflow is published in the existing n8n and live-verified.
- Local post-change gates: 66 backend tests, Ruff, mypy (44 files), and frontend production
build all pass. Evidence is in `artifacts/deployment/unraid-summary.md`.
- Published to the private Gitea repository
@@ -294,20 +296,27 @@ None. External service credentials may be absent; use the documented demo/degrad
- `docker compose run --rm api ruff check .`**All checks passed**.
- `docker compose run --rm api mypy app`**0 issues in 44 files**.
- `cd frontend && npm run lint` — clean TypeScript check.
- `cd frontend && npm run build` — production build succeeded (59 modules; 238.66 kB JS,
36.54 kB CSS before gzip).
- `cd frontend && playwright test --reporter=line`**18 passed** including the full
- `cd frontend && npm run build` — production build succeeded (59 modules; 240.24 kB JS,
36.63 kB CSS before gzip).
- `cd frontend && playwright test --reporter=line`**19 passed** including the full
five-minute demo, every interactive route, return review semantics and four viewport
overflow checks.
- Review deployment updated at `http://192.168.10.150:1236` with persistent PostgreSQL
and n8n volumes preserved. Deployed smoke: all ten authenticated routes plus login at
data preserved. Deployed smoke: all ten authenticated routes plus login at
desktop and mobile sizes rendered without alert state or horizontal overflow; browser console had zero
warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were
healthy and n8n `/healthz` returned `{"status":"ok"}`.
- The repository compose file uses developer ports, so the existing review deployment's
established bindings were restored after archive extraction: web `1236:80`, n8n
`127.0.0.1:15678:5678`, and no host API port. This is deployment configuration only;
no unrelated container was altered.
- Exact next action after this record: commit and push the deployed evidence, update the
deployed source-revision marker to that final documentation commit without rebuilding
unchanged images, then hand the branch off for review. Do not merge master automatically.
healthy and the shared n8n `/healthz` returned `{"status":"ok"}`.
- Corrected the review topology after confirming the host already runs n8n on port 5678:
the temporary `mobilityops-n8n-1` container was removed without deleting its retained
volume; the bundled service is now opt-in through the `bundled-n8n` profile; the API
points to the shared n8n; and the return workflow is imported and published there.
- The existing n8n's previously empty `N8N_HOST` and `N8N_EDITOR_BASE_URL` values were
persistently set in its Unraid template. A synthetic return then completed the full
MobilityOps → shared n8n → callback round trip as `succeeded` on attempt 1, after which
deterministic demo state was restored (`BK-DEMO-RETURN` is `active`).
- Global search is now live for Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
references, including Ctrl/Cmd+K focus and a tested not-found announcement. Final local
Playwright result is **19 passed**.
- Exact next action after this record: commit, push and deploy these final topology/search
corrections, update the deployed source-revision marker, then hand the branch off for
review. Do not merge master automatically.
+20 -17
View File
@@ -22,11 +22,13 @@
| `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) |
| `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) |
| `web` | running, 0 restarts | healthy | `1236:80` on LAN |
| `n8n` | running, 0 restarts | healthy | `127.0.0.1:15678:5678` only |
| shared host `n8n` | running | healthy | `5678:5678` on LAN; outside MobilityOps Compose |
No unrelated container was stopped, recreated, or modified. Port `1236` was confirmed
unused by listeners, Docker containers, and existing Compose projects before deployment.
The requested directory did not exist and was created specifically for MobilityOps.
The final review topology reuses the n8n container that was already running on the host.
Its empty public-host/editor URL settings were corrected in the persistent Unraid template
so workflow execution URLs are valid. The temporary Compose-owned n8n container was
removed without deleting its retained volume. Port `1236` was confirmed unused before the
original deployment; the application directory was created specifically for MobilityOps.
## Deployment commands
@@ -54,15 +56,13 @@ ssh unraid
cd /mnt/user/appdata/mobilityops
tar -xzf .deploy/source.tar.gz
./deploy/unraid/configure-env.sh http://192.168.10.150:1236
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \
python -m app.cli seed --reset
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \
n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \
n8n publish:workflow --id=mobilityops-return-processing
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n
./deploy/unraid/setup-existing-n8n.sh \
n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
```
The server `.env` was created from `.env.example`, is mode `0600`, and contains generated
@@ -88,8 +88,9 @@ included here or in Git.
- Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and
`DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to
returned.
- n8n round trip: event prefix `250f6c0e` reached `succeeded` on attempt 1; the callback
and `return_registered` audit events were visible in the UI.
- Shared-n8n round trip: final verification event `b32a593e-c946-4782-a180-518385b66b50`
reached `succeeded` on attempt 1 with no last error; the deterministic reset afterwards
restored `BK-DEMO-RETURN` to `active`.
- Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related
booking/inspection references.
- Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`;
@@ -102,8 +103,9 @@ included here or in Git.
- ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the
independently authenticated provider endpoints remain available internally to the web
proxy/API boundary, but no live Hub connection is claimed.
- n8n: deployed, healthy, editor loopback-only, owner initialized, workflow imported and
published, and a real return delivery succeeded.
- n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the
MobilityOps workflow is imported/published there and a real return delivery succeeded.
The bundled MobilityOps service is disabled by default in the Unraid overlay.
## Known limitations
@@ -119,7 +121,7 @@ to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, th
```bash
cd /mnt/user/appdata/mobilityops
tar -xzf .deploy/source.tar.gz
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current
curl -fsS http://127.0.0.1:1236/health
```
@@ -130,7 +132,8 @@ curl -fsS http://127.0.0.1:1236/health
cd /mnt/user/appdata/mobilityops
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web n8n
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web
docker logs -f n8n
```
## Safe rollback
@@ -3,7 +3,7 @@
Date: 2026-08-02
Branch: `design/mobilityops-premium-ui`
Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b`
Final design revision: `b3946af4e0e14f2a3acecd212e9f0079e3028fca`
Revision: final branch head reported in the review handoff
Review URL: `http://192.168.10.150:1236`
## Outcome
@@ -45,8 +45,8 @@ return registration gained an evidence-based review boundary before commit.
| Backend tests | 66 passed |
| Backend lint | ruff passed |
| Backend types | mypy: 0 issues in 44 files |
| Frontend types/build | passed; 59 modules; 238.66 kB JS and 36.54 kB CSS before gzip |
| Browser journeys | 18 passed locally |
| Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip |
| Browser journeys | 19 passed locally |
| Horizontal overflow | none at 390/768/1280/1440 px |
| Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support |
| Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes |
@@ -57,7 +57,9 @@ Synthetic-data labelling is persistent on login and authenticated surfaces.
Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`.
The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application,
and `{"status":"ok"}` from n8n's internal health endpoint.
and healthy state from the server's existing n8n at port 5678. A synthetic return reached
`succeeded` on attempt 1 through that shared n8n and its MobilityOps callback; the demo was
then reset to its deterministic state.
## Performance observations
@@ -66,9 +68,12 @@ and CSS visuals, and the production bundle remains appropriate for this internal
## Known limitations
- The global search visual is a non-submitting orientation cue because no cross-entity
query API exists; all route-level search and filters are live. It is intentionally not
presented as an action button.
- Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
references. It intentionally does not offer customer lookup because the locked PoC has
no customer detail route or cross-entity search API.
- The repository retains a bundled n8n service for standalone local clean-checkout demos.
The Unraid overlay keeps it behind the opt-in `bundled-n8n` profile; the live review
deployment uses the server's existing shared n8n instead.
- The MCP Hub state is correctly shown as not configured in the current PoC rather than
simulated as healthy.
- Live RAGcore and MCP Hub round trips remain subject to the existing environment limits
+3
View File
@@ -17,6 +17,9 @@ services:
retries: 10
n8n:
# The review server already has a shared n8n instance. Keep the bundled service
# available for an explicit fallback without starting a second instance by default.
profiles: ["bundled-n8n"]
restart: unless-stopped
ports: !override
- "127.0.0.1:15678:5678"
+17 -14
View File
@@ -1,8 +1,9 @@
# Unraid deployment
MobilityOps is deployed from a committed source archive; the server does not need Gitea
credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml` so that
only the web application is reachable from the LAN.
credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`; of the
MobilityOps-owned services, only the web application is reachable from the LAN. The host's
existing shared n8n remains available on its established port 5678.
## Server layout
@@ -10,7 +11,7 @@ only the web application is reachable from the LAN.
- Compose project: `mobilityops`
- Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`)
- API and PostgreSQL: Compose network only
- n8n editor: server loopback `127.0.0.1:15678` only; use an SSH tunnel for setup
- Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project)
## Deploy
@@ -21,25 +22,26 @@ available. Keep `MCP_HUB_REGISTRATION_ENABLED=false` until the central Hub is re
```bash
cd /mnt/user/appdata/mobilityops
./deploy/unraid/configure-env.sh http://192.168.10.150:1236
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d
./deploy/unraid/configure-env.sh \
http://192.168.10.150:1236 \
http://192.168.10.150:5678/webhook/mobilityops-return
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \
python -m app.cli seed --reset
```
Migrations run automatically in the API entrypoint. Complete the one-time n8n owner
setup without exposing its editor to the LAN, then import and publish the workflow:
Migrations run automatically in the API entrypoint. Import and publish the MobilityOps
workflow into the existing n8n container:
```bash
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \
n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \
n8n publish:workflow --id=mobilityops-return-processing
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n
./deploy/unraid/setup-existing-n8n.sh \
n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
```
The generated n8n owner credentials remain only in the mode-`0600` server `.env`.
The callback token remains server-side and is never written to the repository. The
bundled n8n service is retained only as a standalone fallback behind the explicit
`bundled-n8n` Compose profile; it is not started in this deployment.
## Operate
@@ -48,4 +50,5 @@ cd /mnt/user/appdata/mobilityops
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
docker logs --tail=200 n8n
```
+3 -1
View File
@@ -2,6 +2,7 @@
set -eu
public_url="${1:-http://192.168.10.150:1236}"
n8n_webhook_url="${2:-http://192.168.10.150:5678/webhook/mobilityops-return}"
if [ -e .env ]; then
echo "Refusing to overwrite existing .env" >&2
@@ -25,6 +26,7 @@ sed -i \
-e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
-e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \
-e "s|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=${n8n_webhook_url}|" \
-e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \
-e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \
-e "s|^MOBILITYOPS_CALLBACK_TOKEN=.*|MOBILITYOPS_CALLBACK_TOKEN=${callback_token}|" \
@@ -35,4 +37,4 @@ sed -i \
unset db_password app_secret n8n_key n8n_password callback_token mcp_token
echo "Created server .env with generated secrets for ${public_url}"
echo "Created server .env for ${public_url} using n8n webhook ${n8n_webhook_url}"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
exit 1
fi
if [ ! -f "$source_workflow" ]; then
echo "Missing workflow export: $source_workflow" >&2
exit 1
fi
if ! docker inspect "$container_name" >/dev/null 2>&1; then
echo "Existing n8n container not found: $container_name" >&2
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() {
rm -f "$temporary_workflow"
docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}"
+23
View File
@@ -62,6 +62,29 @@ curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded
A failed/offline n8n does not roll back the return — the outbox event simply stays
`pending`/`failed` and is safely retryable from the Automation page.
### Existing shared n8n on the Unraid review server
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not
start MobilityOps's bundled n8n service. `compose.unraid.yaml` places that fallback behind
the opt-in `bundled-n8n` profile. Configure the API target and publish the workflow with:
```bash
sed -i \
's|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return|' \
.env
./deploy/unraid/setup-existing-n8n.sh \
n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
```
The setup script reads the callback token from the mode-0600 deployment `.env`, builds and
removes a temporary server-side import without writing the token to Git, and restarts the
existing n8n so the production webhook is registered. The imported configuration remains
inside n8n's protected application data. The callback travels through the MobilityOps web
proxy, so the shared n8n container does not need direct database access or membership of
the MobilityOps Docker network.
## Required operational checks
- API and web health (`GET /health`, web root `200`);
+4 -2
View File
@@ -66,8 +66,10 @@ The same widths are automated in `frontend/e2e/ui-redesign.spec.ts`.
- Authenticated network verification through the deployed web proxy returned HTTP 200
for dashboard, vehicles, active booking, open quality issues, knowledge status,
workflows and audit.
- Container verification: PostgreSQL and API healthy, web HTTP 200, n8n `/healthz`
returned `{"status":"ok"}`. Existing database and n8n volumes were preserved.
- Container verification: PostgreSQL and API healthy, web HTTP 200, and the server's
existing n8n on port 5678 returned healthy. No second MobilityOps n8n container is
running. A synthetic return completed the shared-n8n callback on attempt 1 and the demo
was reset to its deterministic start state.
## Performance choices
+10
View File
@@ -22,6 +22,16 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
});
test("global search supports its keyboard shortcut and public references", async ({ page }) => {
await page.keyboard.press("Control+k");
const search = page.getByRole("searchbox", { name: "Search MobilityOps" });
await expect(search).toBeFocused();
await search.fill("MO-024");
await search.press("Enter");
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
});
test("return review separates capture from irreversible commit", async ({ page }) => {
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("60000");
+71 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { FormEvent, useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { BrandMark, Icon, type IconName } from "./Icons";
@@ -25,16 +25,70 @@ const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: strin
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
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 searchInput = useRef<HTMLInputElement>(null);
useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []);
function handleLogout() {
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.");
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 (destination) {
setSearchStatus("");
navigate(destination);
return;
}
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
}
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</a>
@@ -74,12 +128,24 @@ export function Layout() {
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
</button>
<label className="global-search">
<form className="global-search" role="search" onSubmit={handleSearch}>
<Icon name="search" />
<span className="visually-hidden">Search MobilityOps</span>
<input type="search" placeholder="Search fleet, booking or customer…" />
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
<input
id="global-search-input"
ref={searchInput}
type="search"
value={searchQuery}
placeholder="Search fleet, booking or section…"
aria-describedby="global-search-status"
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchStatus("");
}}
/>
<kbd>Ctrl K</kbd>
</label>
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
</form>
<div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && (
+1
View File
@@ -77,6 +77,7 @@ a:hover { color: var(--teal); }
.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 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; }
.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; }