From 6b12d31b575cfc1d1257f022c3341fce8f989b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:57:41 +0000 Subject: [PATCH] Make admin global search work and use one page-title style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global search was a disabled "Search (coming soon)" box that took 220px of the top bar and squeezed the breadcrumb. It is now a working page search: - A compact "Search Ctrl K / ⌘K" button opens a panel with a live filtered list of every page the current session can open (same NAVIGATION tree and visibility gates the sidebar uses). - Matches on page name first (prefix, then word, then substring), then on section, so "security" lists every Security page. - Ctrl+K / Cmd+K opens it from anywhere; arrow keys move, Enter opens, Esc or a click outside closes. - The 1700px rule that hid the old box is removed; the compact trigger leaves the breadcrumb readable at 1600px. Page titles were 18px (Users, Organizations), 20px (SectionHeader) or 22px (Operations pages, some with a divider line under the header). They are now all 22px with the same spacing and no divider. Page descriptions are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014t7uDmnjGZAyMrCYpN2RZy --- .../cc-ui/src/components/shell/AppShell.tsx | 1 + .../components/shell/GlobalSearch.test.tsx | 56 ++++++ .../src/components/shell/GlobalSearch.tsx | 187 +++++++++++++++--- .../cc-ui/src/components/shell/TopAppBar.tsx | 7 +- .../cc-ui/src/components/ui/SectionHeader.tsx | 2 +- frontend/cc-ui/src/index.css | 6 - frontend/cc-ui/src/pages/ActionsPage.tsx | 2 +- frontend/cc-ui/src/pages/CloudPage.tsx | 2 +- frontend/cc-ui/src/pages/ConfigPage.tsx | 2 +- frontend/cc-ui/src/pages/DockerPage.tsx | 2 +- frontend/cc-ui/src/pages/HealthPage.tsx | 2 +- frontend/cc-ui/src/pages/KnownIssuesPage.tsx | 2 +- frontend/cc-ui/src/pages/LlmPage.tsx | 2 +- .../cc-ui/src/pages/OrganizationsPage.tsx | 4 +- .../cc-ui/src/pages/ScheduledJobsPage.tsx | 2 +- frontend/cc-ui/src/pages/UsersPage.tsx | 4 +- 16 files changed, 240 insertions(+), 43 deletions(-) create mode 100644 frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx diff --git a/frontend/cc-ui/src/components/shell/AppShell.tsx b/frontend/cc-ui/src/components/shell/AppShell.tsx index 86e39e4d..0480d1e9 100644 --- a/frontend/cc-ui/src/components/shell/AppShell.tsx +++ b/frontend/cc-ui/src/components/shell/AppShell.tsx @@ -52,6 +52,7 @@ export default function AppShell({ active, onNavigate, user, onSignOut, extraAct user={user} onSignOut={onSignOut} onMenuToggle={() => setMobileNavOpen(o => !o)} + onNavigate={handleNavigate} extraActions={extraActions} />
diff --git a/frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx b/frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx new file mode 100644 index 00000000..aafa0fac --- /dev/null +++ b/frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as auth from '../../auth' +import GlobalSearch, { matchPages, searchablePages } from './GlobalSearch' + +vi.mock('../../auth', async (orig) => { + const actual = await orig() + return { ...actual, hasPlatformAdminAccess: vi.fn(() => true), hasOrganizationsAccess: vi.fn(() => true), hasAdminAccess: vi.fn(() => true) } +}) + +describe('GlobalSearch', () => { + beforeEach(() => vi.clearAllMocks()) + + it('ranks name matches before section matches', () => { + const results = matchPages(searchablePages(), 'hipaa').map(r => r.label) + expect(results.slice(0, 2)).toEqual(['HIPAA Readiness', 'HIPAA Audit Report']) + const security = matchPages(searchablePages(), 'security').map(r => r.label) + expect(security).toContain('MFA Policy') // matched by its section + }) + + it('only offers pages the session can see', () => { + vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(false) + const labels = searchablePages().map(r => r.label) + expect(labels).not.toContain('HIPAA Readiness') + expect(labels).toContain('Sessions') + }) + + it('opens with Ctrl+K, filters as you type, and opens the page on Enter', () => { + const onNavigate = vi.fn() + render() + fireEvent.keyDown(window, { key: 'k', ctrlKey: true }) + const input = screen.getByRole('combobox', { name: 'Search pages' }) + fireEvent.change(input, { target: { value: 'llm' } }) + expect(screen.getByRole('option', { name: /LLMs/ })).toHaveAttribute('aria-selected', 'true') + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onNavigate).toHaveBeenCalledWith('llms') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('moves with arrow keys, says when nothing matches, and closes on Escape', () => { + const onNavigate = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Search/ })) + const input = screen.getByRole('combobox', { name: 'Search pages' }) + fireEvent.change(input, { target: { value: 'audit' } }) + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onNavigate).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: /Search/ })) + fireEvent.change(screen.getByRole('combobox', { name: 'Search pages' }), { target: { value: 'zzzz' } }) + expect(screen.getByText(/No pages match/)).toBeInTheDocument() + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Search pages' }), { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/cc-ui/src/components/shell/GlobalSearch.tsx b/frontend/cc-ui/src/components/shell/GlobalSearch.tsx index 75438304..090fa910 100644 --- a/frontend/cc-ui/src/components/shell/GlobalSearch.tsx +++ b/frontend/cc-ui/src/components/shell/GlobalSearch.tsx @@ -1,32 +1,175 @@ +import { useEffect, useMemo, useRef, useState } from 'react' import { Search } from 'lucide-react' +import { NAVIGATION, isNavItemVisible, type PageKey } from '../../navigation' + +interface Entry { key: PageKey; label: string; section: string } + +/** Every page the current session can open, from the same NAVIGATION + * tree (and the same visibility gates) the sidebar renders. */ +export function searchablePages(): Entry[] { + const out: Entry[] = [] + for (const section of NAVIGATION) { + for (const item of section.items.filter(isNavItemVisible)) { + const sectionLabel = section.label || 'Home' + if (item.children?.length) { + for (const child of item.children.filter(isNavItemVisible)) { + out.push({ key: child.key, label: child.label, section: `${sectionLabel} › ${item.label}` }) + } + } else { + out.push({ key: item.key, label: item.label, section: sectionLabel }) + } + } + } + return out +} + +/** Pages whose name matches first (prefix before substring), then pages + * whose section matches -- "security" finds every Security page. */ +export function matchPages(entries: Entry[], query: string): Entry[] { + const q = query.trim().toLowerCase() + if (!q) return entries + const score = (e: Entry) => { + const label = e.label.toLowerCase() + if (label.startsWith(q)) return 0 + if (label.split(/[\s/&-]+/).some(w => w.startsWith(q))) return 1 + if (label.includes(q)) return 2 + if (e.section.toLowerCase().includes(q)) return 3 + return -1 + } + return entries + .map(e => [score(e), e] as const) + .filter(([s]) => s >= 0) + .sort((a, b) => a[0] - b[0]) + .map(([, e]) => e) +} + +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) /** - * Admin Console Phase 2: global search placeholder, per this phase's - * explicit scope -- a real visual presence in the shell, deliberately - * non-functional (disabled input, no search implementation) rather than - * a fake box that silently does nothing when a user types into it. + * Admin Console global search: jump to any page by name. A compact + * trigger in the top bar (so it doesn't squeeze the breadcrumb) opens a + * panel with a live-filtered list; Ctrl+K / Cmd+K opens it from anywhere, + * arrow keys move, Enter opens, Esc closes. */ -export default function GlobalSearch() { +export default function GlobalSearch({ onNavigate }: { onNavigate: (key: PageKey) => void }) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [index, setIndex] = useState(0) + const rootRef = useRef(null) + const inputRef = useRef(null) + + // Recomputed per open so visibility follows the current session. + const entries = useMemo(() => (open ? searchablePages() : []), [open]) + const results = useMemo(() => matchPages(entries, query), [entries, query]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setOpen(o => !o) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + useEffect(() => { + if (!open) return + setQuery('') + setIndex(0) + inputRef.current?.focus() + const onDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [open]) + + const go = (entry: Entry | undefined) => { + if (!entry) return + onNavigate(entry.key) + setOpen(false) + } + + const onInputKey = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setIndex(i => Math.min(i + 1, results.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setIndex(i => Math.max(i - 1, 0)) } + else if (e.key === 'Enter') { e.preventDefault(); go(results[index]) } + else if (e.key === 'Escape') { e.preventDefault(); setOpen(false) } + } + return ( -
- - + + + {open && ( +
+
+ + { setQuery(e.target.value); setIndex(0) }} + onKeyDown={onInputKey} + placeholder="Go to page…" + aria-label="Search pages" + role="combobox" + aria-expanded + aria-controls="global-search-results" + aria-activedescendant={results[index] ? `global-search-${results[index].key}` : undefined} + style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'var(--text)', fontSize: 13 }} + /> +
+
    + {results.length === 0 && ( +
  • No pages match “{query}”.
  • + )} + {results.map((r, i) => ( +
  • setIndex(i)} + onMouseDown={e => { e.preventDefault(); go(r) }} + style={{ + display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 10px', borderRadius: 6, + cursor: 'pointer', fontSize: 13, + background: i === index ? 'var(--accent-dim, rgba(0,212,170,0.1))' : 'transparent', + color: i === index ? 'var(--text)' : 'var(--text2)', + }} + > + {r.label} + {r.section} +
  • + ))} +
+
+ )}
) } diff --git a/frontend/cc-ui/src/components/shell/TopAppBar.tsx b/frontend/cc-ui/src/components/shell/TopAppBar.tsx index d680f097..a4c24aec 100644 --- a/frontend/cc-ui/src/components/shell/TopAppBar.tsx +++ b/frontend/cc-ui/src/components/shell/TopAppBar.tsx @@ -1,5 +1,6 @@ import { Menu } from 'lucide-react' import type { SessionUser } from '../../auth' +import type { PageKey } from '../../navigation' import Breadcrumb from './Breadcrumb' import GlobalSearch from './GlobalSearch' import NotificationsMenu from './NotificationsMenu' @@ -13,6 +14,8 @@ interface Props { user: SessionUser | null onSignOut: () => void onMenuToggle: () => void + /** Opens a page from the global search. */ + onNavigate: (key: PageKey) => void /** Page-contextual extra actions (e.g. the pre-existing status chip + * Refresh/Generate Report controls, relocated from the old Header.tsx * unchanged in behavior) -- optional so most pages don't need it. */ @@ -28,7 +31,7 @@ interface Props { * (non-placeholder) context switcher here -- OrgSelector next to it * remains its own documented placeholder. */ -export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, extraActions }: Props) { +export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, onNavigate, extraActions }: Props) { return (
@@ -49,7 +52,7 @@ export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, e
{extraActions &&
{extraActions}
} - + diff --git a/frontend/cc-ui/src/components/ui/SectionHeader.tsx b/frontend/cc-ui/src/components/ui/SectionHeader.tsx index 291ed40e..6bb40fa8 100644 --- a/frontend/cc-ui/src/components/ui/SectionHeader.tsx +++ b/frontend/cc-ui/src/components/ui/SectionHeader.tsx @@ -17,7 +17,7 @@ export default function SectionHeader({ title, description, actions }: Props) { gap: 16, marginBottom: 20, }}>
-

+

{title}

{description && ( diff --git a/frontend/cc-ui/src/index.css b/frontend/cc-ui/src/index.css index 9d517977..bfb60b68 100644 --- a/frontend/cc-ui/src/index.css +++ b/frontend/cc-ui/src/index.css @@ -150,12 +150,6 @@ select option { background: var(--surface); color: var(--text); } .shell-org-selector-label { display: none; } .shell-profile-label { display: none; } } -/* The top-bar search is a disabled "coming soon" placeholder; below - this width it crowded the breadcrumb down to "Administrat… › Us…". - The breadcrumb tells the admin where they are, so it wins. */ -@media (max-width: 1700px) { - .shell-topbar-search { display: none !important; } -} @media (max-width: 560px) { .shell-content { padding: 16px !important; } .shell-stat-grid { grid-template-columns: 1fr 1fr !important; } diff --git a/frontend/cc-ui/src/pages/ActionsPage.tsx b/frontend/cc-ui/src/pages/ActionsPage.tsx index d38fc3c6..306cacc1 100644 --- a/frontend/cc-ui/src/pages/ActionsPage.tsx +++ b/frontend/cc-ui/src/pages/ActionsPage.tsx @@ -124,7 +124,7 @@ export default function ActionsPage() { return (
-
+

Actions

Regenerate the ecosystem report, or refresh control-center's own test coverage on demand. diff --git a/frontend/cc-ui/src/pages/CloudPage.tsx b/frontend/cc-ui/src/pages/CloudPage.tsx index 217fdc76..80e81d5a 100644 --- a/frontend/cc-ui/src/pages/CloudPage.tsx +++ b/frontend/cc-ui/src/pages/CloudPage.tsx @@ -55,7 +55,7 @@ export default function CloudPage({ refreshKey }: { refreshKey: number }) { return (

-
+

Execution Backends

Cloud and HPC execution backend configuration status diff --git a/frontend/cc-ui/src/pages/ConfigPage.tsx b/frontend/cc-ui/src/pages/ConfigPage.tsx index 4703306e..53f84246 100644 --- a/frontend/cc-ui/src/pages/ConfigPage.tsx +++ b/frontend/cc-ui/src/pages/ConfigPage.tsx @@ -133,7 +133,7 @@ export default function ConfigPage({ refreshKey }: { refreshKey: number }) { return (

{/* Hero */} -
+

Configuration

Live configuration served from the backend

diff --git a/frontend/cc-ui/src/pages/DockerPage.tsx b/frontend/cc-ui/src/pages/DockerPage.tsx index 239b05c8..7f93c506 100644 --- a/frontend/cc-ui/src/pages/DockerPage.tsx +++ b/frontend/cc-ui/src/pages/DockerPage.tsx @@ -558,7 +558,7 @@ export default function DockerPage({ refreshKey }: { refreshKey: number }) { return (
{/* Hero */} -
+

Docker Images

Platform containers, tool SIF images, and plugin Docker images diff --git a/frontend/cc-ui/src/pages/HealthPage.tsx b/frontend/cc-ui/src/pages/HealthPage.tsx index 97a4a642..18bed2b6 100644 --- a/frontend/cc-ui/src/pages/HealthPage.tsx +++ b/frontend/cc-ui/src/pages/HealthPage.tsx @@ -219,7 +219,7 @@ export default function HealthPage({ refreshKey }: Props) { return (

{/* Hero */} -
+

Health Dashboard

OmniBioAI Ecosystem · Stateless health monitoring diff --git a/frontend/cc-ui/src/pages/KnownIssuesPage.tsx b/frontend/cc-ui/src/pages/KnownIssuesPage.tsx index b1fa35c3..f2b85ec1 100644 --- a/frontend/cc-ui/src/pages/KnownIssuesPage.tsx +++ b/frontend/cc-ui/src/pages/KnownIssuesPage.tsx @@ -86,7 +86,7 @@ export default function KnownIssuesPage() { return (

-
+

Known Issues

Tracked platform issues. Only issues marked public appear on the public dashboard diff --git a/frontend/cc-ui/src/pages/LlmPage.tsx b/frontend/cc-ui/src/pages/LlmPage.tsx index 66c9566a..0af70a1d 100644 --- a/frontend/cc-ui/src/pages/LlmPage.tsx +++ b/frontend/cc-ui/src/pages/LlmPage.tsx @@ -41,7 +41,7 @@ export default function LlmPage({ refreshKey }: { refreshKey: number }) { return (

-
+

LLMs

Local Ollama models and cloud API key status diff --git a/frontend/cc-ui/src/pages/OrganizationsPage.tsx b/frontend/cc-ui/src/pages/OrganizationsPage.tsx index c49c53f7..a412a897 100644 --- a/frontend/cc-ui/src/pages/OrganizationsPage.tsx +++ b/frontend/cc-ui/src/pages/OrganizationsPage.tsx @@ -303,8 +303,8 @@ export default function OrganizationsPage({ onSelect, title, description }: Prop

-

{title ?? 'Organizations'}

-

+

{title ?? 'Organizations'}

+

{description ?? (isPlatformAdmin ? 'Every organization in the system. This view is only reachable with platform-admin permissions, enforced by the backend on every request.' : 'Organizations you belong to.')} diff --git a/frontend/cc-ui/src/pages/ScheduledJobsPage.tsx b/frontend/cc-ui/src/pages/ScheduledJobsPage.tsx index f500177a..2a9a7312 100644 --- a/frontend/cc-ui/src/pages/ScheduledJobsPage.tsx +++ b/frontend/cc-ui/src/pages/ScheduledJobsPage.tsx @@ -100,7 +100,7 @@ export default function ScheduledJobsPage() { return (

-
+

Scheduled Jobs

Host crontab jobs -- backups, nightly coverage, sync jobs.

diff --git a/frontend/cc-ui/src/pages/UsersPage.tsx b/frontend/cc-ui/src/pages/UsersPage.tsx index 284158da..a7c6c625 100644 --- a/frontend/cc-ui/src/pages/UsersPage.tsx +++ b/frontend/cc-ui/src/pages/UsersPage.tsx @@ -124,8 +124,8 @@ export default function UsersPage({ onSelect }: Props) { return (
-

Users

-

+

Users

+

Every user in the system. This view is only reachable with platform-admin permissions, enforced by the backend on every request.