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
100 changes: 100 additions & 0 deletions core/http/react-ui/e2e/role-mode-adaptive.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test, expect } from './coverage-fixtures.js'

// These specs stub /api/features and /api/auth/status per cell. The test server
// disables auth (isAdmin=true) and reports its own features, so we intercept
// before navigation to simulate each role x mode cell.

function stubFeatures(page, features) {
return page.route('**/api/features', route =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(features) }))
}

function stubNoP2P(page) {
// P2P token endpoint returns empty -> p2pEnabled=false.
return page.route('**/api/p2p/token', route =>
route.fulfill({ contentType: 'text/plain', body: '' }))
}

test.describe('Adaptive landing (HomeRoute)', () => {
test('admin + distributed redirects /app to Nodes', async ({ page }) => {
await stubFeatures(page, { distributed: true })
await stubNoP2P(page)
await page.goto('/app')
await expect(page).toHaveURL(/\/app\/nodes$/)
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
})

test('admin + single-node stays on Home', async ({ page }) => {
await stubFeatures(page, { distributed: false })
await stubNoP2P(page)
await page.goto('/app')
await expect(page).toHaveURL(/\/app$/)
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
})
})

test.describe('Adaptive sidebar', () => {
test('distributed pins the Cluster group with Nodes at the top', async ({ page }) => {
await stubFeatures(page, { distributed: true })
await stubNoP2P(page)
await page.goto('/app/chat') // any in-app page so the sidebar is mounted
const pinned = page.locator('.sidebar-nav .sidebar-section-items').first()
await expect(pinned.getByText('Nodes', { exact: false })).toBeVisible({ timeout: 15_000 })
})

test('single-node does not pin a Cluster group', async ({ page }) => {
await stubFeatures(page, { distributed: false })
await stubNoP2P(page)
await page.goto('/app/chat')
// Nodes is reachable only via the Operate rail, not pinned at the top.
await expect(page.locator('.sidebar-nav')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.sidebar-nav .sidebar-section-items').first()
.getByText('Nodes', { exact: false })).toHaveCount(0)
})
})

test.describe('Top navbar', () => {
test('admin sees the mode pill and settings cog', async ({ page }) => {
await stubFeatures(page, { distributed: true })
await stubNoP2P(page)
await page.goto('/app/chat')
await expect(page.locator('.top-navbar__mode')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.top-navbar__icon[aria-label]')).not.toHaveCount(0)
})

test('admin-via-chat jump shows when localai_assistant is enabled', async ({ page }) => {
await stubFeatures(page, { distributed: false, localai_assistant: true })
await stubNoP2P(page)
await page.goto('/app/chat')
await expect(page.locator('.top-navbar__assistant')).toBeVisible({ timeout: 15_000 })
})

test('admin-via-chat jump hidden when localai_assistant is off', async ({ page }) => {
await stubFeatures(page, { distributed: false, localai_assistant: false })
await stubNoP2P(page)
await page.goto('/app/chat')
await expect(page.locator('.top-navbar__assistant')).toHaveCount(0)
})
})

test.describe('Token usage meter', () => {
test('renders when admin usage has data', async ({ page }) => {
await stubFeatures(page, { distributed: false })
await stubNoP2P(page)
await page.route('**/api/auth/admin/usage**', route =>
route.fulfill({ contentType: 'application/json',
body: JSON.stringify({ buckets: [{ total_tokens: 1234 }] }) }))
await page.goto('/app/chat')
await expect(page.locator('.top-navbar__meter')).toBeVisible({ timeout: 15_000 })
})

test('hidden when admin usage is empty (graceful degrade)', async ({ page }) => {
await stubFeatures(page, { distributed: false })
await stubNoP2P(page)
await page.route('**/api/auth/admin/usage**', route =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buckets: [] }) }))
await page.goto('/app/chat')
await expect(page.locator('.top-navbar')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.top-navbar__meter')).toHaveCount(0)
})
})
10 changes: 10 additions & 0 deletions core/http/react-ui/public/locales/en/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
"accountSettings": "Account settings",
"account": "Account",
"accountFor": "Account: {{name}}",
"topbar": {
"label": "Top bar",
"modeDistributed": "Distributed",
"modeSwarm": "Swarm",
"modeSingle": "Single-node",
"pickModel": "Models",
"adminViaChat": "Admin via chat",
"tokensToday": "Tokens today",
"usageDetail": "View usage detail"
},
"sections": {
"create": "Create",
"recognition": "Recognition",
Expand Down
44 changes: 44 additions & 0 deletions core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,50 @@
font-size: 1.5rem;
}

/* Desktop top bar: deployment + admin affordances on wide screens. Hidden on
mobile, where .mobile-header carries the equivalent actions. */
.top-navbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-lg);
border-bottom: 1px solid var(--color-border-default);
background: var(--color-bg-secondary);
}
.top-navbar__right { display: flex; align-items: center; gap: var(--spacing-sm); }
.top-navbar__mode {
font-size: 0.75rem;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--color-border-default);
color: var(--color-text-secondary);
}
.top-navbar__mode.is-active { color: var(--color-success); border-color: var(--color-success); }
.top-navbar__btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.8125rem; padding: 5px 10px; border-radius: 8px;
border: 1px solid var(--color-border-default); background: var(--color-bg-tertiary);
color: var(--color-text-primary); cursor: pointer;
}
.top-navbar__icon {
width: 32px; height: 32px; display: inline-flex; align-items: center;
justify-content: center; border-radius: 8px; border: 1px solid var(--color-border-default);
background: var(--color-bg-tertiary); color: var(--color-text-secondary); cursor: pointer;
}
.top-navbar__avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.top-navbar__meter {
display: inline-flex; flex-direction: column; gap: 3px; align-items: flex-start;
padding: 4px 10px; border-radius: 8px; border: 1px solid var(--color-border-default);
background: var(--color-bg-tertiary); cursor: pointer; min-width: 150px;
}
.top-navbar__meter-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
.top-navbar__meter-bar { width: 100%; height: 5px; border-radius: 3px; background: var(--color-bg-secondary); overflow: hidden; }
.top-navbar__meter-bar i { display: block; height: 100%; background: var(--color-primary); }
@media (max-width: 639px) {
.top-navbar { display: none; }
}

/* Sidebar */
.sidebar {
position: fixed;
Expand Down
2 changes: 2 additions & 0 deletions core/http/react-ui/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Sidebar from './components/Sidebar'
import OperationsBar from './components/OperationsBar'
import TopNavbar from './components/TopNavbar'
import { ToastContainer, useToast } from './components/Toast'
import { systemApi } from './utils/api'
import { useTheme } from './contexts/ThemeContext'
Expand Down Expand Up @@ -98,6 +99,7 @@ export default function App() {
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="main-content" {...(sidebarOpen ? { 'aria-hidden': 'true', inert: '' } : {})}>
<OperationsBar />
<TopNavbar />
{/* Mobile header — primary actions reachable without opening the
drawer. Hamburger is the only way to expand the nav on phones;
theme toggle and account avatar are mirrored from the sidebar
Expand Down
28 changes: 28 additions & 0 deletions core/http/react-ui/src/components/HomeRoute.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { lazy, Suspense } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useDeployment } from '../contexts/DeploymentContext'
import { resolveHome } from '../utils/resolveHome'
import RouteFallback from './RouteFallback'

const Home = lazy(() => import('../pages/Home'))

// Index-route element. Waits for auth + deployment signals to load (so we never
// flash the wrong landing), then either renders Home or redirects to the cell's
// landing page. Redirecting (rather than rendering Nodes/Chat inline at /app)
// keeps each target's own route guard, active-nav state, and deep-linkability.
export default function HomeRoute() {
const { isAdmin, loading: authLoading } = useAuth()
const { distributed, p2pEnabled, loading: deployLoading } = useDeployment()

if (authLoading || deployLoading) return <RouteFallback />

const target = resolveHome({ isAdmin, distributed, p2pEnabled })
if (target) return <Navigate to={target} replace />

return (
<Suspense fallback={<RouteFallback />}>
<Home />
</Suspense>
)
}
58 changes: 48 additions & 10 deletions core/http/react-ui/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import ThemeToggle from './ThemeToggle'
import LanguageSwitcher from './LanguageSwitcher'
import { useAuth } from '../context/AuthContext'
import { useBranding } from '../contexts/BrandingContext'
import { useDeployment } from '../contexts/DeploymentContext'
import { apiUrl } from '../utils/basePath'
import { preloadRoute } from '../router'
import { consoles, firstVisiblePath, consolePaths } from './console/consoleConfig'
import { clusterPinItems, shouldCollapseCreate } from '../utils/sidebarPolicy'

const COLLAPSED_KEY = 'localai_sidebar_collapsed'
const SECTIONS_KEY = 'localai_sidebar_sections'
Expand Down Expand Up @@ -58,11 +60,13 @@ function NavItem({ item, onClose, collapsed }) {
)
}

function loadSectionState() {
// Tiers render expanded by default (the redesign favours showing the few
// intent groups up front); users can still collapse any tier and the choice
// is persisted. Stored values override the defaults so a saved collapse wins.
function loadSectionState(collapseCreate = false) {
// Tiers render expanded by default; users can collapse any tier and the
// choice persists (stored values override defaults). In cluster cells we
// start Create collapsed so the pinned cluster group leads - but only when
// the user has not already expressed a preference.
const defaults = Object.fromEntries(sections.map(s => [s.id, true]))
if (collapseCreate) defaults.create = false
try {
const stored = localStorage.getItem(SECTIONS_KEY)
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults
Expand All @@ -77,20 +81,34 @@ function saveSectionState(state) {

export default function Sidebar({ isOpen, onClose }) {
const { t } = useTranslation('nav')
const [features, setFeatures] = useState({})
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
// Deployment shape (server features + p2p) drives the adaptive sidebar; the
// shared context replaces the sidebar's own /api/features fetch so the
// landing resolver, navbar, and this policy agree on one snapshot.
const deployment = useDeployment()
const features = deployment.features
// Shared shape for the console gating helpers (consoleConfig.js); in scope for
// both the pinned cluster group and the console-tier rendering below.
const auth = { isAdmin, authEnabled, hasFeature, features }
const collapseCreate = shouldCollapseCreate(auth, deployment)
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
})
const [openSections, setOpenSections] = useState(loadSectionState)
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
const branding = useBranding()
const navigate = useNavigate()
const location = useLocation()
const closeBtnRef = useRef(null)

// Apply the cluster-cell Create-collapse default once, only when the user has
// no stored section preference (so we never override an explicit choice).
useEffect(() => {
fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {})
}, [])
if (deployment.loading) return
let hasStored = false
try { hasStored = !!localStorage.getItem(SECTIONS_KEY) } catch { hasStored = false }
if (hasStored || !collapseCreate) return
setOpenSections(prev => (prev.create === false ? prev : { ...prev, create: false }))
}, [deployment.loading, collapseCreate])

// Stay in sync with external collapse dispatches (e.g. the chat
// page's focus mode). The collapse-toggle button still owns the
Expand Down Expand Up @@ -157,8 +175,6 @@ export default function Sidebar({ isOpen, onClose }) {
}

const visibleTopItems = topItems.filter(filterItem)
// Shared shape for the console gating helpers (consoleConfig.js).
const auth = { isAdmin, authEnabled, hasFeature, features }

// Inline sections (Create) carry no gating; a plain filterItem pass suffices.
const getVisibleSectionItems = (section) => section.items.filter(filterItem)
Expand Down Expand Up @@ -199,6 +215,28 @@ export default function Sidebar({ isOpen, onClose }) {
))}
</div>

{/* Pinned Cluster quick-access (admin + distributed/p2p). Same gate
as the Operate rail; surfaced at the top for cluster operators. */}
{(() => {
const pinned = clusterPinItems(auth, deployment)
if (pinned.length === 0) return null
return (
<div className="sidebar-section">
<div className="sidebar-section-title">{t('operate.cluster')}</div>
<div className="sidebar-section-items">
{pinned.map(item => (
<NavItem
key={item.path}
item={{ path: item.path, icon: item.icon, labelKey: item.labelKey }}
onClose={onClose}
collapsed={collapsed}
/>
))}
</div>
</div>
)
})()}

{/* Collapsible sections */}
{sections.map(section => {
const visibleItems = getVisibleSectionItems(section)
Expand Down
Loading
Loading