Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/cc-ui/src/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
Expand Down
56 changes: 56 additions & 0 deletions frontend/cc-ui/src/components/shell/GlobalSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../auth')>()
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(<GlobalSearch onNavigate={onNavigate} />)
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(<GlobalSearch onNavigate={onNavigate} />)
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()
})
})
187 changes: 165 additions & 22 deletions frontend/cc-ui/src/components/shell/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div
className="shell-topbar-search"
style={{
display: 'flex', alignItems: 'center', gap: 8,
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
padding: '6px 10px', background: 'var(--bg2)', color: 'var(--muted)',
width: 220,
}}
>
<Search size={14} />
<input
disabled
placeholder="Search (coming soon)"
title="Global search is not implemented yet"
<div ref={rootRef} className="shell-topbar-search" style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-haspopup="dialog"
aria-expanded={open}
style={{
border: 'none', background: 'transparent', color: 'var(--muted)',
fontSize: 12, width: '100%', cursor: 'not-allowed',
display: 'flex', alignItems: 'center', gap: 8,
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
padding: '6px 10px', background: 'var(--bg2)', color: 'var(--muted)', fontSize: 12, cursor: 'pointer',
}}
/>
>
<Search size={14} />
<span>Search</span>
<kbd style={{ fontSize: 10, padding: '1px 5px', border: '1px solid var(--border)', borderRadius: 4, fontFamily: 'var(--mono)' }}>
{isMac ? '⌘K' : 'Ctrl K'}
</kbd>
</button>

{open && (
<div
role="dialog"
aria-label="Search pages"
style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, width: 380, zIndex: 50,
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
boxShadow: '0 12px 32px rgba(0,0,0,0.35)', overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
<Search size={14} color="var(--muted)" />
<input
ref={inputRef}
value={query}
onChange={e => { 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 }}
/>
</div>
<ul id="global-search-results" role="listbox" style={{ listStyle: 'none', maxHeight: 360, overflowY: 'auto', padding: 4 }}>
{results.length === 0 && (
<li style={{ padding: '12px 10px', fontSize: 12, color: 'var(--muted)' }}>No pages match “{query}”.</li>
)}
{results.map((r, i) => (
<li
key={r.key}
id={`global-search-${r.key}`}
role="option"
aria-selected={i === index}
onMouseEnter={() => 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)',
}}
>
<span>{r.label}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{r.section}</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
7 changes: 5 additions & 2 deletions frontend/cc-ui/src/components/shell/TopAppBar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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. */
Expand All @@ -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 (
<header style={header}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0, flex: 1 }}>
Expand All @@ -49,7 +52,7 @@ export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, e
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
{extraActions && <div className="shell-topbar-extra" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>{extraActions}</div>}
<GlobalSearch />
<GlobalSearch onNavigate={onNavigate} />
<OrgSelector user={user} />
<TeamSwitcher user={user} />
<ThemeToggle />
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/components/ui/SectionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function SectionHeader({ title, description, actions }: Props) {
gap: 16, marginBottom: 20,
}}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.01em' }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.01em' }}>
{title}
</h1>
{description && (
Expand Down
6 changes: 0 additions & 6 deletions frontend/cc-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/ActionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export default function ActionsPage() {

return (
<div>
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Actions</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>
Regenerate the ecosystem report, or refresh control-center's own test coverage on demand.
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/CloudPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function CloudPage({ refreshKey }: { refreshKey: number }) {

return (
<div>
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Execution Backends</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>
Cloud and HPC execution backend configuration status
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default function ConfigPage({ refreshKey }: { refreshKey: number }) {
return (
<div>
{/* Hero */}
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Configuration</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>Live configuration served from the backend</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/DockerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ export default function DockerPage({ refreshKey }: { refreshKey: number }) {
return (
<div>
{/* Hero */}
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Docker Images</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>
Platform containers, tool SIF images, and plugin Docker images
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/HealthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export default function HealthPage({ refreshKey }: Props) {
return (
<div>
{/* Hero */}
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Health Dashboard</h1>
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 10 }}>
OmniBioAI Ecosystem · Stateless health monitoring
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/KnownIssuesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default function KnownIssuesPage() {

return (
<div>
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>Known Issues</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>
Tracked platform issues. Only issues marked public appear on the public dashboard
Expand Down
2 changes: 1 addition & 1 deletion frontend/cc-ui/src/pages/LlmPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function LlmPage({ refreshKey }: { refreshKey: number }) {

return (
<div>
<div style={{ marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid var(--border)' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>LLMs</h1>
<p style={{ fontSize: 13, color: 'var(--muted)' }}>
Local Ollama models and cloud API key status
Expand Down
4 changes: 2 additions & 2 deletions frontend/cc-ui/src/pages/OrganizationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@ export default function OrganizationsPage({ onSelect, title, description }: Prop
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 4 }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)' }}>{title ?? 'Organizations'}</h2>
<p style={{ fontSize: 12, color: 'var(--muted)', marginTop: 4 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{title ?? 'Organizations'}</h1>
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>
{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.')}
Expand Down
Loading
Loading