diff --git a/core/http/react-ui/e2e/role-mode-adaptive.spec.js b/core/http/react-ui/e2e/role-mode-adaptive.spec.js new file mode 100644 index 000000000000..0e2e1b37b4c9 --- /dev/null +++ b/core/http/react-ui/e2e/role-mode-adaptive.spec.js @@ -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) + }) +}) diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 5423438f9a18..7317c74cdf46 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -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", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index cf1a46bd39d4..0238f2fb1c8c 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index b922499b55c1..37ebf384f23d 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -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' @@ -98,6 +99,7 @@ export default function App() { setSidebarOpen(false)} />
+ {/* 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 diff --git a/core/http/react-ui/src/components/HomeRoute.jsx b/core/http/react-ui/src/components/HomeRoute.jsx new file mode 100644 index 000000000000..6e0008d8f63f --- /dev/null +++ b/core/http/react-ui/src/components/HomeRoute.jsx @@ -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 + + const target = resolveHome({ isAdmin, distributed, p2pEnabled }) + if (target) return + + return ( + }> + + + ) +} diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 58438fd5184d..679897e33460 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -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' @@ -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 @@ -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 @@ -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) @@ -199,6 +215,28 @@ export default function Sidebar({ isOpen, onClose }) { ))} + {/* 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 ( +
+
{t('operate.cluster')}
+
+ {pinned.map(item => ( + + ))} +
+
+ ) + })()} + {/* Collapsible sections */} {sections.map(section => { const visibleItems = getVisibleSectionItems(section) diff --git a/core/http/react-ui/src/components/TopNavbar.jsx b/core/http/react-ui/src/components/TopNavbar.jsx new file mode 100644 index 000000000000..a1227b0a9ebb --- /dev/null +++ b/core/http/react-ui/src/components/TopNavbar.jsx @@ -0,0 +1,96 @@ +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useAuth } from '../context/AuthContext' +import { useDeployment } from '../contexts/DeploymentContext' +import { useTheme } from '../contexts/ThemeContext' +import { launchAssistantChat } from '../utils/launchAssistantChat' +import TokenUsageMeter from './navbar/TokenUsageMeter' + +// Desktop top bar. Complementary to the mobile-only header in App.jsx: this is +// hidden on small screens (see .top-navbar CSS) and shows deployment/admin +// affordances on wide screens where the sidebar footer is far from the content. +export default function TopNavbar() { + const { t } = useTranslation('nav') + const navigate = useNavigate() + const { isAdmin, authEnabled, user } = useAuth() + const { features, distributed, p2pEnabled } = useDeployment() + const { theme, toggleTheme } = useTheme() + + const modeLabel = distributed + ? t('topbar.modeDistributed') + : p2pEnabled + ? t('topbar.modeSwarm') + : t('topbar.modeSingle') + + const showAssistantJump = isAdmin && !!features.localai_assistant + const showAvatar = authEnabled && user + const themeLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode') + + return ( +
+
+ {isAdmin && ( + +