From 029bd9aa2f76475a4589559c4f01d6c74a831cab Mon Sep 17 00:00:00 2001 From: hehuilin Date: Tue, 8 Sep 2026 23:18:16 +0800 Subject: [PATCH 1/8] feat(webui): SOC workspace as top-level menu with browser-style tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏:删除「场景工作区」分组,SOC 工作区升为一级分组,设备接入移入智能体工作室;AI 工作台与工作区分组做成手风琴,同一时刻只展开一个,进入对应页面自动切换并持久化 - 二级菜单:AI 工作台、SOC 工作区、智能体工作室都支持拖拽和 Alt+方向键排序,按分组存 localStorage - 顶部标签栏:打开过的侧栏入口成为标签,可切换、关闭、拖动排序,记住最后停留的子路径;「+」菜单按分组列出未打开页面 - keep-alive:路由子表抽到 routes/contentRoutes.tsx,Layout 为每个打开过的标签保留面板,非当前面板 visibility:hidden + inert,状态与滚动位置保留;刷新后恢复的标签懒挂载 - 工作区宿主页:根路径直接跳默认页,去掉页内页签,修复 t 进依赖导致的重复拉取 - SOC 态势大屏插件:三处 100vh 改为按容器高度,soc_ui 升到 1.1.6 --- .flocks/flockshub/index.json | 2 +- .../plugins/webuis/soc_ui/manifest.json | 2 +- .../webuis/soc_ui/soc_dashboard/manifest.json | 2 +- .../webuis/soc_ui/soc_dashboard/src/Page.tsx | 6 +- .../plugins/webuis/soc_ui/workspace.json | 2 +- tests/hub/test_hub_catalog.py | 6 +- .../src/components/layout/KeepAlivePanes.tsx | 96 ++ webui/src/components/layout/Layout.test.tsx | 838 +++++++++++------- webui/src/components/layout/Layout.tsx | 683 ++++++++------ webui/src/components/layout/OpenTabsBar.tsx | 208 +++++ webui/src/hooks/useLayoutOpenTabs.ts | 25 + webui/src/hooks/useWorkspacePageOrders.ts | 42 + webui/src/locales/en-US/nav.json | 5 +- .../src/locales/en-US/webuiContractPage.json | 4 - webui/src/locales/zh-CN/nav.json | 5 +- .../src/locales/zh-CN/webuiContractPage.json | 4 - .../WebUIContractWorkspaceHost/index.test.tsx | 80 +- .../WebUIContractWorkspaceHost/index.tsx | 41 +- webui/src/routes/contentRoutes.tsx | 143 +++ webui/src/routes/index.tsx | 141 +-- webui/src/utils/layoutTabs.test.ts | 71 ++ webui/src/utils/layoutTabs.ts | 104 +++ .../utils/webuiContractWorkspaceSections.ts | 21 + webui/src/utils/workspaceNavOrder.test.ts | 90 ++ webui/src/utils/workspaceNavOrder.ts | 109 +++ 25 files changed, 1967 insertions(+), 763 deletions(-) create mode 100644 webui/src/components/layout/KeepAlivePanes.tsx create mode 100644 webui/src/components/layout/OpenTabsBar.tsx create mode 100644 webui/src/hooks/useLayoutOpenTabs.ts create mode 100644 webui/src/hooks/useWorkspacePageOrders.ts create mode 100644 webui/src/routes/contentRoutes.tsx create mode 100644 webui/src/utils/layoutTabs.test.ts create mode 100644 webui/src/utils/layoutTabs.ts create mode 100644 webui/src/utils/workspaceNavOrder.test.ts create mode 100644 webui/src/utils/workspaceNavOrder.ts diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 4d3560e97..5d5a7e92a 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14640,7 +14640,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "category": "workflow-automation", "tags": [ "siem", diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json index 25c7d5cdd..cfa3f5464 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json @@ -5,7 +5,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "author": "Flocks Team", "license": "MIT", "homepage": "", diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json index afabbbcb0..ddab530e0 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json @@ -8,5 +8,5 @@ "enabled": true, "placement": "home.after", "entry": "src/index.tsx", - "updatedAt": 1781509788949 + "updatedAt": 1788857311724 } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx index c08e3d499..9e0092c6a 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -2981,7 +2981,7 @@ export default function Page() { const CSS = ` .adtd-root { box-sizing: border-box; - min-height: 100vh; + min-height: 100%; min-width: 1100px; margin: 0; padding: 0 16px; @@ -4296,7 +4296,7 @@ const CSS = ` --command-blue: #3677bc; --command-border: rgba(255, 255, 255, .09); position: relative; - height: 100vh; + height: 100%; min-height: 720px; min-width: 1180px; margin: 0; @@ -4516,7 +4516,7 @@ const CSS = ` z-index: 2; display: grid; grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 330px); - height: calc(100vh - 68px); + height: calc(100% - 68px); min-height: 652px; transition: grid-template-columns .24s ease; } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json index 3bf045c38..730c229bd 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json @@ -1,6 +1,6 @@ { "id": "soc_ui", - "version": "1.1.5", + "version": "1.1.6", "title": "SOC 工作区", "titleEn": "SOC Workspace", "icon": "ShieldCheck", diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 60315efa6..831e7c15e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -296,17 +296,17 @@ def test_catalog_uses_webui_workspace_version_for_inferred_installs( entry = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] - assert entry.version == "1.1.5" + assert entry.version == "1.1.6" assert entry.state == "updateAvailable" assert entry.installedVersion == "1.0.0" - workspace["version"] = "1.1.5" + workspace["version"] = "1.1.6" workspace_path.write_text(json.dumps(workspace), encoding="utf-8") refreshed = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] assert refreshed.state == "installed" - assert refreshed.installedVersion == "1.1.5" + assert refreshed.installedVersion == "1.1.6" def test_pentest_agents_are_listed_in_agent_catalog(): diff --git a/webui/src/components/layout/KeepAlivePanes.tsx b/webui/src/components/layout/KeepAlivePanes.tsx new file mode 100644 index 000000000..2a96c0dda --- /dev/null +++ b/webui/src/components/layout/KeepAlivePanes.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react'; +import { + matchPath, + NavigationType, + UNSAFE_LocationContext as LocationContext, + useRoutes, + type Location, + type RouteObject, +} from 'react-router-dom'; +import { FULL_SCREEN_PATH_PATTERNS } from '@/routes/contentRoutes'; + +export interface KeepAlivePane { + /** Tab identity (sidebar entry href). */ + href: string; +} + +interface KeepAlivePanesProps { + panes: KeepAlivePane[]; + activeHref: string | null; + /** The router's live location; rendered by the active pane. */ + location: Location; + routes: RouteObject[]; +} + +export function isFullScreenPath(pathname: string): boolean { + return FULL_SCREEN_PATH_PATTERNS.some((pattern) => matchPath(pattern, pathname) !== null); +} + +interface PaneProps { + active: boolean; + location: Location; + routes: RouteObject[]; +} + +function Pane({ active, location, routes }: PaneProps) { + // Match this pane's own location, and pin `useLocation()` for everything + // inside it, so a hidden tab never reacts to the active tab's route. + const element = useRoutes(routes, location); + const fullScreen = isFullScreenPath(location.pathname); + return ( + +
+ {fullScreen ? element : ( +
+
{element}
+
+ )} +
+
+ ); +} + +/** + * Browser-style tabs keep their page alive: one pane per open tab stays + * mounted (state, scroll position, in-progress edits included) and only the + * active one is visible. Inactive panes are hidden with `visibility` rather + * than `display: none` so their layout and scroll offsets survive. + */ +export default function KeepAlivePanes({ panes, activeHref, location, routes }: KeepAlivePanesProps) { + // Remember the last live location of each pane so a hidden pane keeps the + // exact location object it last rendered with (stable identity, no re-runs). + // A pane is only mounted once it has been active (tabs restored from a + // previous session stay cold until they are opened), like a browser that + // discards background tabs. + const lastLocationsRef = useRef(new Map()); + if (activeHref) { + lastLocationsRef.current.set(activeHref, location); + } + for (const href of Array.from(lastLocationsRef.current.keys())) { + if (!panes.some((pane) => pane.href === href)) lastLocationsRef.current.delete(href); + } + + // Charts and editors measure their container; poke them when a pane comes back. + useEffect(() => { + if (!activeHref) return; + const timer = window.setTimeout(() => window.dispatchEvent(new Event('resize')), 0); + return () => window.clearTimeout(timer); + }, [activeHref]); + + return ( +
+ {panes.map((pane) => { + const active = pane.href === activeHref; + const lastLocation = lastLocationsRef.current.get(pane.href); + if (!active && !lastLocation) return null; + const paneLocation = active ? location : (lastLocation as Location); + return ; + })} +
+ ); +} diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index 9e7347f06..07112858b 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { Link, MemoryRouter, Route, Routes, useLocation, type RouteObject } from 'react-router-dom'; import Layout from './Layout'; import Home from '@/pages/Home'; import { UPDATE_DISMISSED_KEY } from '@/utils/updateDismissal'; @@ -233,36 +233,136 @@ function makeOnboardingStatus(overrides: Record = {}) { }; } +// Pages rendered inside the layout under test: Home plus a probe for every +// other path. The layout keeps one pane per open tab mounted, so the probe +// also carries local state to prove a hidden tab survives untouched. +const testContentRoutes: RouteObject[] = [ + { index: true, element: }, + { path: 'sessions', element: }, + { path: 'workflows/*', element: }, + { path: 'contracts/webui/workspaces/:workspaceId/:pageId?', element: }, + { path: '*', element: }, +]; + function renderHomeWithLayout() { - return render( - - - }> - } /> - - - , - ); + return renderLayoutAt('/'); +} + +function renderHomeWithLayoutAndSessionsRoute() { + return renderLayoutAt('/'); +} + +/** The location probe of the visible (active) pane; hidden panes keep their own. */ +function activeProbe(): HTMLElement { + const probe = screen.getAllByTestId('location-probe') + .find((element) => element.closest('[data-keep-alive-pane="active"]')); + if (!probe) throw new Error('no active location probe'); + return probe; } -function LocationProbe() { +function RouteProbe() { const location = useLocation(); - return
{`${location.pathname}${location.search}`}
; + const [count, setCount] = React.useState(0); + return ( +
+
{`${location.pathname}${location.search}`}
+ + {count} + go-soc-alerts + go-sessions + go-workflow-detail +
+ ); } -function renderHomeWithLayoutAndSessionsRoute() { +function renderLayoutAt(path: string) { return render( - + - }> - } /> - } /> - + } /> , ); } +function makeSocPage(id: string, title: string, icon: string, order: number) { + return { + id, + title, + route: `/contracts/webui/${id}`, + icon, + order, + enabled: true, + placement: 'home.after', + buildHash: 'ready', + buildStatus: 'ready' as const, + workspaceId: 'soc_ui', + workspaceTitle: 'SOC 工作区', + workspaceRoute: '/contracts/webui/workspaces/soc_ui', + }; +} + +function mockSocWorkspaceNav() { + const socPages = [ + makeSocPage('soc-dashboard', '态势', 'Activity', 10), + makeSocPage('soc-overview', 'SOC 总览', 'ShieldCheck', 15), + makeSocPage('soc-alerts', '告警调查', 'AlertTriangle', 20), + ]; + useWebUIContractPages.mockReturnValue({ + pages: socPages, + workspaces: [ + { + id: 'soc_ui', + title: 'SOC 工作区', + titleEn: 'SOC Workspace', + route: '/contracts/webui/workspaces/soc_ui', + icon: 'ShieldCheck', + order: 10, + enabled: true, + placement: 'sceneWorkspace', + defaultPageId: 'soc-overview', + sections: [ + { + id: 'posture', + label: '态势', + pageIds: ['soc-dashboard'], + defaultPageId: 'soc-dashboard', + contentPadding: 'none', + themeOverride: 'dark', + }, + { + id: 'operations', + label: '告警运营', + pageIds: ['soc-overview', 'soc-alerts'], + defaultPageId: 'soc-overview', + contentPadding: 'none', + }, + ], + pages: socPages, + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + return socPages; +} + +function openTabNames(): string[] { + return within(screen.getByRole('tablist', { name: 'openTabs' })) + .getAllByRole('tab') + .map((tab) => tab.textContent ?? ''); +} + +function closeTabButton(name: string): HTMLElement { + const tab = screen.getByRole('tab', { name }); + return within(tab.parentElement as HTMLElement).getByRole('button', { name: 'closeTab' }); +} + +function navPageOrder(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-nav-page-id]')).map((element) => element.getAttribute('data-nav-page-id') ?? ''); +} + async function flushEffects() { await act(async () => { if (vi.isFakeTimers()) { @@ -1070,63 +1170,72 @@ describe('Layout WebUI contract pages navigation', () => { ); }); - it('keeps sidebar workspace groups expanded by default and allows collapsing each group', async () => { + it('opens only one of the AI workbench and SOC workspace groups at a time', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); renderHomeWithLayout(); const aiWorkbenchToggle = await screen.findByRole('button', { name: 'aiWorkbench' }); - const sceneWorkspacesToggle = screen.getByRole('button', { name: 'sceneWorkspaces' }); + const socToggle = screen.getByRole('button', { name: 'SOC 工作区' }); const agentHubToggle = screen.getByRole('button', { name: 'agentHub' }); + expect(screen.queryByRole('button', { name: 'sceneWorkspaces' })).not.toBeInTheDocument(); expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); - expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'true'); + expect(socToggle).toHaveAttribute('aria-expanded', 'false'); expect(agentHubToggle).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '告警调查' })).not.toBeInTheDocument(); expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); - await user.click(aiWorkbenchToggle); + await user.click(socToggle); + expect(socToggle).toHaveAttribute('aria-expanded', 'true'); expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['aiWorkbench'])); + expect(agentHubToggle).toHaveAttribute('aria-expanded', 'true'); + expect(localStorage.getItem('flocks_layout_expanded_primary_nav_section')).toBe('workspace:soc_ui'); expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '告警调查' })).toHaveAttribute( + 'href', + '/contracts/webui/workspaces/soc_ui/soc-alerts', + ); + + await user.click(socToggle); + expect(socToggle).toHaveAttribute('aria-expanded', 'false'); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem('flocks_layout_expanded_primary_nav_section')).toBe(''); + expect(screen.queryByRole('link', { name: '告警调查' })).not.toBeInTheDocument(); + await user.click(aiWorkbenchToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); + expect(socToggle).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem('flocks_layout_expanded_primary_nav_section')).toBe('aiWorkbench'); expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - await user.click(sceneWorkspacesToggle); - expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['sceneWorkspaces'])); - expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); - await user.click(sceneWorkspacesToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - await user.click(agentHubToggle); expect(agentHubToggle).toHaveAttribute('aria-expanded', 'false'); expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['agentHub'])); expect(screen.queryByRole('link', { name: 'agents' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); await user.click(agentHubToggle); expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); }); - it('restores collapsed sidebar workspace groups after refresh', async () => { + it('restores the expanded primary group and collapsed agent studio after refresh', async () => { localStorage.setItem('flocks_onboarding_dismissed', 'true'); - localStorage.setItem('flocks_layout_collapsed_nav_sections', JSON.stringify(['sceneWorkspaces'])); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + localStorage.setItem('flocks_layout_collapsed_nav_sections', JSON.stringify(['agentHub'])); + mockSocWorkspaceNav(); renderHomeWithLayout(); - expect(await screen.findByRole('button', { name: 'sceneWorkspaces' })).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'agentHub' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'SOC 工作区' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('link', { name: '告警调查' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'agentHub' })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('link', { name: 'agents' })).not.toBeInTheDocument(); }); it('does not render WebUI contract page links until their build is ready', async () => { @@ -1166,312 +1275,182 @@ describe('Layout WebUI contract pages navigation', () => { expect(screen.queryByRole('link', { name: '失败页面' })).not.toBeInTheDocument(); }); - it('renders WebUI workspaces and device integration in the scene workspace group', async () => { - const user = userEvent.setup(); - const workspacePages = [ - { - id: 'risk-dashboard', - title: '态势看板', - route: '/contracts/webui/risk-dashboard', - icon: 'Activity', - order: 30, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - { - id: 'ops-overview', - title: '运营总览', - route: '/contracts/webui/ops-overview', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - { - id: 'investigation-list', - title: '调查列表', - route: '/contracts/webui/investigation-list', - icon: 'AlertTriangle', - order: 20, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: workspacePages, - workspaces: [ - { - id: 'scene_workspace', - title: '场景工作区', - route: '/contracts/webui/workspaces/scene_workspace', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'ops-overview', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['risk-dashboard'], - defaultPageId: 'risk-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - { - id: 'operations', - label: '调查列表', - pageIds: ['ops-overview', 'investigation-list'], - defaultPageId: 'ops-overview', - contentPadding: 'comfortable', - }, - ], - pages: workspacePages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); + it('renders the SOC workspace as a top-level menu and moves device integration into the agent studio', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + mockSocWorkspaceNav(); const { container } = renderHomeWithLayout(); - const workspaceLink = await screen.findByRole('link', { name: '场景工作区' }); - expect(workspaceLink).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace', - ); - expect(workspaceLink.querySelectorAll('svg')).toHaveLength(2); - expect(screen.queryByRole('link', { name: '调查列表' })).not.toBeInTheDocument(); - - const sectionHeadings = Array.from(container.querySelectorAll('h3')).map((element) => element.textContent); - expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeGreaterThanOrEqual(0); - expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeLessThan(sectionHeadings.indexOf('agentHub')); - expect(sectionHeadings).not.toContain('systemCenter'); - - const sceneSection = Array.from(container.querySelectorAll('h3')) - .find((heading) => heading.textContent === 'sceneWorkspaces') - ?.parentElement; - expect(sceneSection?.querySelector('a[href="/contracts/webui/workspaces/scene_workspace"]')).not.toBeNull(); - expect(sceneSection?.querySelector('a[href="/devices"]')).not.toBeNull(); - - const agentSection = Array.from(container.querySelectorAll('h3')) - .find((heading) => heading.textContent === 'agentHub') - ?.parentElement; - expect(agentSection?.querySelector('a[href="/devices"]')).toBeNull(); - expect(agentSection?.querySelector('a[href="/models"]')).not.toBeNull(); - expect(agentSection?.querySelector('a[href="/channels"]')).not.toBeNull(); - - await user.click(workspaceLink); - - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - expect(workspaceMenu).toBeInTheDocument(); - expect(workspaceMenu).toHaveClass('w-52'); - expect(workspaceMenu).toHaveClass('bg-zinc-100'); - expect(screen.getByRole('link', { name: '态势' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/risk-dashboard', - ); - expect(screen.getByRole('link', { name: '运营总览' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/ops-overview', - ); - expect(screen.getAllByRole('link', { name: '调查列表' }).find((link) => link.getAttribute('href')?.endsWith('/investigation-list'))).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/investigation-list', - ); + const socToggle = await screen.findByRole('button', { name: 'SOC 工作区' }); + const sidebarNav = container.querySelector('aside nav') as HTMLElement; + const sectionHeadings = Array.from(sidebarNav.querySelectorAll('h3')).map((element) => element.textContent); + expect(sectionHeadings).toEqual(['aiWorkbench', 'SOC 工作区', 'agentHub']); + expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); + + const socSection = socToggle.closest('div.mb-6') as HTMLElement; + expect(Array.from(socSection.querySelectorAll('a')).map((link) => link.getAttribute('href'))).toEqual([ + '/contracts/webui/workspaces/soc_ui/soc-dashboard', + '/contracts/webui/workspaces/soc_ui/soc-overview', + '/contracts/webui/workspaces/soc_ui/soc-alerts', + ]); + expect(screen.getByRole('link', { name: '态势' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'SOC 总览' })).toBeInTheDocument(); + + const agentSection = screen.getByRole('button', { name: 'agentHub' }).closest('div.mb-6') as HTMLElement; + expect(Array.from(agentSection.querySelectorAll('a')).map((link) => link.getAttribute('href'))).toEqual([ + '/agents', + '/skills', + '/tools', + '/devices', + '/hub', + '/models', + '/channels', + ]); + expect(screen.getByRole('link', { name: 'deviceIntegration' })).toHaveAttribute('href', '/devices'); + }); + + it('reorders SOC workspace pages by dragging and keeps the order after refresh', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + mockSocWorkspaceNav(); + + const first = renderHomeWithLayout(); + await screen.findByRole('link', { name: '告警调查' }); + expect(navPageOrder(first.container)).toEqual(['soc-dashboard', 'soc-overview', 'soc-alerts']); + + const itemFor = (pageId: string) => first.container.querySelector(`[data-nav-page-id="${pageId}"]`) as HTMLElement; + fireEvent.dragStart(itemFor('soc-alerts')); + fireEvent.dragOver(itemFor('soc-dashboard')); + expect(itemFor('soc-dashboard').className).toContain('ring-2'); + fireEvent.drop(itemFor('soc-dashboard')); + fireEvent.dragEnd(itemFor('soc-alerts')); + + expect(JSON.parse(localStorage.getItem('flocks_layout_workspace_page_order:soc_ui') ?? '[]')).toEqual([ + 'soc-alerts', + 'soc-dashboard', + 'soc-overview', + ]); + expect(navPageOrder(first.container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + expect(itemFor('soc-dashboard').className).not.toContain('ring-2'); + + first.unmount(); + const second = renderHomeWithLayout(); + await screen.findByRole('link', { name: '告警调查' }); + expect(navPageOrder(second.container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + }); - const workspaceMenuScope = within(workspaceMenu); - const collapseButtons = workspaceMenuScope.getAllByTitle('workspace.collapseSidebar'); - await user.click(collapseButtons[collapseButtons.length - 1]); - expect(screen.queryByRole('link', { name: '运营总览' })).not.toBeInTheDocument(); + it('moves a SOC workspace page with Alt+Arrow keys', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + mockSocWorkspaceNav(); - await user.click(workspaceMenuScope.getByTitle('workspace.expandSidebar')); - expect(screen.getByRole('link', { name: '运营总览' })).toBeInTheDocument(); + const { container } = renderHomeWithLayout(); + const alertsLink = await screen.findByRole('link', { name: '告警调查' }); - await user.click(workspaceMenuScope.getByRole('button', { name: '调查列表' })); - expect(screen.queryByRole('link', { name: '运营总览' })).not.toBeInTheDocument(); + fireEvent.keyDown(alertsLink, { key: 'ArrowUp', altKey: true }); + expect(navPageOrder(container)).toEqual(['soc-dashboard', 'soc-alerts', 'soc-overview']); - await user.click(workspaceMenuScope.getByRole('button', { name: '调查列表' })); - expect(screen.getByRole('link', { name: '运营总览' })).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole('link', { name: '告警调查' }), { key: 'ArrowUp', altKey: true }); + expect(navPageOrder(container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); - await user.unhover(workspaceLink); + // Without Alt the arrow keys are left alone and nothing moves. + fireEvent.keyDown(screen.getByRole('link', { name: '告警调查' }), { key: 'ArrowDown' }); + expect(navPageOrder(container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + }); + it('switches the open group to follow the current route', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + + const aiWorkbenchToggle = await screen.findByRole('button', { name: 'aiWorkbench' }); + const socToggle = screen.getByRole('button', { name: 'SOC 工作区' }); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); + expect(socToggle).toHaveAttribute('aria-expanded', 'false'); + + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts')); await waitFor(() => { - expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); + expect(socToggle).toHaveAttribute('aria-expanded', 'true'); }); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem('flocks_layout_expanded_primary_nav_section')).toBe('workspace:soc_ui'); + expect(screen.getByRole('link', { name: '告警调查' })).toHaveClass('bg-white'); + + await user.click(screen.getByRole('link', { name: 'go-sessions' })); + await waitFor(() => { + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); + }); + expect(socToggle).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem('flocks_layout_expanded_primary_nav_section')).toBe('aiWorkbench'); }); it('starts a SOC-scoped custom page session from the SOC workspace menu', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); sessionApi.create.mockResolvedValueOnce({ id: 'session-soc-custom-page' }); - - const socPages = [ - { - id: 'soc-dashboard', - title: '告警态势', - route: '/contracts/webui/soc-dashboard', - icon: 'Activity', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - { - id: 'soc-alerts', - title: '告警调查', - route: '/contracts/webui/soc-alerts', - icon: 'AlertTriangle', - order: 20, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: socPages, - workspaces: [ - { - id: 'soc_ui', - title: 'SOC 工作区', - route: '/contracts/webui/workspaces/soc_ui', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'soc-alerts', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['soc-dashboard'], - defaultPageId: 'soc-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - { - id: 'operations', - label: '告警运营', - pageIds: ['soc-alerts'], - defaultPageId: 'soc-alerts', - contentPadding: 'none', - }, - ], - pages: socPages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); + mockSocWorkspaceNav(); renderHomeWithLayoutAndSessionsRoute(); - await user.click(await screen.findByRole('link', { name: 'SOC 工作区' })); - - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - const workspaceMenuScope = within(workspaceMenu); - expect(workspaceMenuScope.getByRole('link', { name: '态势' })).toHaveAttribute( + const socToggle = await screen.findByRole('button', { name: 'SOC 工作区' }); + const socSection = socToggle.closest('div.mb-6') as HTMLElement; + expect(within(socSection).getByRole('link', { name: '态势' })).toHaveAttribute( 'href', '/contracts/webui/workspaces/soc_ui/soc-dashboard', ); - expect(workspaceMenuScope.getByRole('link', { name: '告警运营' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/soc_ui/soc-alerts', - ); - await user.click(workspaceMenuScope.getByRole('button', { name: 'workspace.customPage' })); + await user.click(within(socSection).getByRole('button', { name: 'workspace.customPage' })); await waitFor(() => { expect(sessionApi.create).toHaveBeenCalledWith({ title: 'workspace.customPageSessionTitle' }); }); - expect(await screen.findByTestId('location-probe')).toHaveTextContent( + await waitFor(() => expect(activeProbe()).toHaveTextContent( `/sessions?session=session-soc-custom-page&message=${encodeURIComponent('workspace.socCustomPageInitialMessage')}&display=${encodeURIComponent('workspace.socCustomPageDisplayLabel')}`, - ); + )); + }); + + it('hides the SOC custom page action from members', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + useAuth.mockReturnValue({ + user: { + id: 'user-2', + username: 'member', + role: 'member', + status: 'active', + must_reset_password: false, + }, + logout: vi.fn(), + }); + mockSocWorkspaceNav(); + + renderHomeWithLayout(); + + const socToggle = await screen.findByRole('button', { name: 'SOC 工作区' }); + const socSection = socToggle.closest('div.mb-6') as HTMLElement; + expect(within(socSection).queryByRole('button', { name: 'workspace.customPage' })).not.toBeInTheDocument(); + expect(within(socSection).getByRole('button', { name: 'workspace.customTitle' })).toBeInTheDocument(); }); it('customizes the SOC dashboard title from the SOC workspace menu', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); const titleChanged = vi.fn(); window.addEventListener('soc-dashboard:title-changed', titleChanged); - - const socPages = [ - { - id: 'soc-dashboard', - title: '告警态势', - route: '/contracts/webui/soc-dashboard', - icon: 'Activity', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: socPages, - workspaces: [ - { - id: 'soc_ui', - title: 'SOC 工作区', - route: '/contracts/webui/workspaces/soc_ui', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'soc-dashboard', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['soc-dashboard'], - defaultPageId: 'soc-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - ], - pages: socPages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); + mockSocWorkspaceNav(); try { renderHomeWithLayout(); - await user.click(await screen.findByRole('link', { name: 'SOC 工作区' })); - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - await user.click(within(workspaceMenu).getByRole('button', { name: 'workspace.customTitle' })); + const socToggle = await screen.findByRole('button', { name: 'SOC 工作区' }); + const socSection = socToggle.closest('div.mb-6') as HTMLElement; + await user.click(within(socSection).getByRole('button', { name: 'workspace.customTitle' })); const input = screen.getByLabelText('workspace.customTitle'); await user.clear(input); @@ -1487,4 +1466,243 @@ describe('Layout WebUI contract pages navigation', () => { window.removeEventListener('soc-dashboard:title-changed', titleChanged); } }); + + it('opens visited sidebar entries as browser-style tabs, switches and closes them', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + + await screen.findByRole('button', { name: 'SOC 工作区' }); + expect(openTabNames()).toEqual(['sessions']); + expect(screen.getByRole('tab', { name: 'sessions' })).toHaveAttribute('aria-current', 'page'); + + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => { + expect(openTabNames()).toEqual(['sessions', '告警调查']); + }); + expect(screen.getByRole('tab', { name: '告警调查' })).toHaveAttribute('aria-current', 'page'); + expect(JSON.parse(localStorage.getItem('flocks_layout_open_tabs') ?? '[]')).toEqual([ + { href: '/sessions', path: '/sessions' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', path: '/contracts/webui/workspaces/soc_ui/soc-alerts' }, + ]); + + // Switching through a tab keeps both open. + await user.click(screen.getByRole('tab', { name: 'sessions' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/sessions')); + expect(openTabNames()).toEqual(['sessions', '告警调查']); + + // Closing an inactive tab only removes it. + await user.click(closeTabButton('告警调查')); + await waitFor(() => { + expect(openTabNames()).toEqual(['sessions']); + }); + expect(activeProbe()).toHaveTextContent('/sessions'); + + // Closing the active tab activates its neighbour. + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => { + expect(openTabNames()).toEqual(['sessions', '告警调查']); + }); + await user.click(closeTabButton('告警调查')); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/sessions'); + }); + expect(openTabNames()).toEqual(['sessions']); + + // Closing the last tab goes home, which opens the home tab. + await user.click(closeTabButton('sessions')); + await waitFor(() => { + expect(openTabNames()).toEqual(['flocksHome']); + }); + expect(screen.getByRole('tab', { name: 'flocksHome' })).toHaveAttribute('aria-current', 'page'); + }); + + it('remembers the last visited path inside a tab', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/workflows/wf-1'); + + await screen.findByRole('button', { name: 'SOC 工作区' }); + expect(openTabNames()).toEqual(['workflows']); + expect(screen.getByRole('tab', { name: 'workflows' })).toHaveAttribute('href', '/workflows/wf-1'); + + await user.click(screen.getByRole('link', { name: 'go-sessions' })); + await waitFor(() => { + expect(openTabNames()).toEqual(['workflows', 'sessions']); + }); + + await user.click(screen.getByRole('tab', { name: 'workflows' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/workflows/wf-1'); + }); + expect(JSON.parse(localStorage.getItem('flocks_layout_open_tabs') ?? '[]')).toEqual([ + { href: '/workflows', path: '/workflows/wf-1' }, + { href: '/sessions', path: '/sessions' }, + ]); + }); + + it('lists closed entries grouped by sidebar section in the "+" menu and opens one', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + + await screen.findByRole('button', { name: 'SOC 工作区' }); + await user.click(screen.getByRole('button', { name: 'openPage' })); + const menu = screen.getByRole('menu', { name: 'openPage' }); + const items = within(menu).getAllByRole('menuitem').map((item) => item.textContent); + expect(items).toEqual([ + 'flocksHome', + 'workspace', + 'tasks', + 'workflows', + '态势', + 'SOC 总览', + '告警调查', + 'agents', + 'skills', + 'tools', + 'deviceIntegration', + 'hub', + 'models', + 'channels', + ]); + expect(within(menu).getByText('SOC 工作区')).toBeInTheDocument(); + expect(within(menu).queryByRole('menuitem', { name: 'sessions' })).not.toBeInTheDocument(); + + await user.click(within(menu).getByRole('menuitem', { name: '告警调查' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts'); + }); + expect(openTabNames()).toEqual(['sessions', '告警调查']); + expect(screen.queryByRole('menu', { name: 'openPage' })).not.toBeInTheDocument(); + }); + + it('restores open tabs after refresh and drops entries that no longer exist', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_open_tabs', JSON.stringify([ + { href: '/workflows', path: '/workflows/wf-9' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', path: '/contracts/webui/workspaces/soc_ui/soc-alerts' }, + { href: '/contracts/webui/workspaces/old_ws/gone', path: '/contracts/webui/workspaces/old_ws/gone' }, + ])); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + + await screen.findByRole('button', { name: 'SOC 工作区' }); + await waitFor(() => { + expect(openTabNames()).toEqual(['workflows', '告警调查', 'sessions']); + }); + expect(screen.getByRole('tab', { name: 'workflows' })).toHaveAttribute('href', '/workflows/wf-9'); + expect(screen.getByRole('tab', { name: 'sessions' })).toHaveAttribute('aria-current', 'page'); + }); + + it('reorders AI workbench entries by dragging and keeps the order after refresh', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + const first = renderHomeWithLayout(); + await screen.findByRole('button', { name: 'SOC 工作区' }); + const keysIn = (container: HTMLElement, sectionName: string) => { + const section = within(container).getByRole('button', { name: sectionName }).closest('div.mb-6') as HTMLElement; + return Array.from(section.querySelectorAll('[data-nav-item-key]')).map((element) => element.getAttribute('data-nav-item-key')); + }; + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/sessions', '/workspace', '/tasks', '/workflows']); + expect(keysIn(first.container, 'agentHub')).toEqual(['/agents', '/skills', '/tools', '/devices', '/hub', '/models', '/channels']); + + const itemFor = (key: string) => first.container.querySelector(`[data-nav-item-key="${key}"]`) as HTMLElement; + fireEvent.dragStart(itemFor('/workflows')); + fireEvent.dragOver(itemFor('/sessions')); + fireEvent.drop(itemFor('/sessions')); + fireEvent.dragEnd(itemFor('/workflows')); + + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/workspace', '/tasks']); + expect(JSON.parse(localStorage.getItem('flocks_layout_nav_item_order:aiWorkbench') ?? '[]')) + .toEqual(['/workflows', '/sessions', '/workspace', '/tasks']); + // Other groups are untouched. + expect(keysIn(first.container, 'agentHub')).toEqual(['/agents', '/skills', '/tools', '/devices', '/hub', '/models', '/channels']); + + // Keyboard works for built-in entries as well. + fireEvent.keyDown(within(first.container).getByRole('link', { name: 'tasks' }), { key: 'ArrowUp', altKey: true }); + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/tasks', '/workspace']); + + first.unmount(); + const second = renderHomeWithLayout(); + await screen.findByRole('button', { name: 'SOC 工作区' }); + expect(keysIn(second.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/tasks', '/workspace']); + }); + + it('reorders open tabs by dragging them like browser tabs', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_open_tabs', JSON.stringify([ + { href: '/sessions', path: '/sessions' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', path: '/contracts/webui/workspaces/soc_ui/soc-alerts' }, + { href: '/workflows', path: '/workflows/wf-3' }, + ])); + mockSocWorkspaceNav(); + + const { container } = renderLayoutAt('/sessions'); + await screen.findByRole('button', { name: 'SOC 工作区' }); + await waitFor(() => { + expect(openTabNames()).toEqual(['sessions', '告警调查', 'workflows']); + }); + + const tabFor = (href: string) => container.querySelector(`[data-tab-href="${href}"]`) as HTMLElement; + fireEvent.dragStart(tabFor('/workflows')); + fireEvent.dragOver(tabFor('/sessions')); + expect(tabFor('/sessions').className).toContain('ring-2'); + fireEvent.drop(tabFor('/sessions')); + fireEvent.dragEnd(tabFor('/workflows')); + + expect(openTabNames()).toEqual(['workflows', 'sessions', '告警调查']); + expect(tabFor('/sessions').className).not.toContain('ring-2'); + expect(JSON.parse(localStorage.getItem('flocks_layout_open_tabs') ?? '[]')).toEqual([ + { href: '/workflows', path: '/workflows/wf-3' }, + { href: '/sessions', path: '/sessions' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', path: '/contracts/webui/workspaces/soc_ui/soc-alerts' }, + ]); + // The active tab keeps its highlight after being moved around. + expect(screen.getByRole('tab', { name: 'sessions' })).toHaveAttribute('aria-current', 'page'); + }); + + it('keeps a page mounted with its state while another tab is active', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + await screen.findByRole('button', { name: 'SOC 工作区' }); + + await user.click(screen.getByRole('button', { name: 'count-up' })); + await user.click(screen.getByRole('button', { name: 'count-up' })); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts'); + }); + // The sessions pane is still mounted, just hidden. + const panes = document.querySelectorAll('[data-keep-alive-pane]'); + expect(Array.from(panes).map((pane) => pane.getAttribute('data-keep-alive-pane'))).toEqual(['inactive', 'active']); + expect(panes[0]).toHaveAttribute('aria-hidden', 'true'); + expect(within(panes[0] as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('0'); + + await user.click(screen.getByRole('tab', { name: 'sessions' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/sessions'); + }); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + + // Closing a tab unmounts its pane. + await user.click(closeTabButton('告警调查')); + await waitFor(() => { + expect(document.querySelectorAll('[data-keep-alive-pane]')).toHaveLength(1); + }); + }); }); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index 0a50be13d..13333cc57 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import { Outlet, Link, useLocation, matchPath, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate, type RouteObject } from 'react-router-dom'; import { Home, MessageSquare, @@ -23,17 +23,31 @@ import { ArrowUpCircle, RefreshCw, Gauge, + GripVertical, Loader2, + Pencil, + Plus, type LucideIcon, } from 'lucide-react'; import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; -import type { ComponentType, CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'; +import type { + ComponentType, + CSSProperties, + DragEvent as ReactDragEvent, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from 'react'; import { useTranslation } from 'react-i18next'; import { onboardingAPI } from '@/api/onboarding'; // Modals are only rendered after the user clicks/triggers them; pulling them // into the eager Layout chunk costs ~1.7k LOC + i18n keys + lucide icons that // the home page never needs. const COLLAPSED_NAV_SECTIONS_KEY = 'flocks_layout_collapsed_nav_sections'; +const EXPANDED_PRIMARY_NAV_SECTION_KEY = 'flocks_layout_expanded_primary_nav_section'; +const WORKSPACE_NAV_SECTION_PREFIX = 'workspace:'; +const AI_WORKBENCH_NAV_SECTION_ID = 'aiWorkbench'; +// Pane used for routes that are not a sidebar entry (they are not kept as tabs). +const TRANSIENT_PANE_HREF = '__current__'; const SIDEBAR_WIDTH_KEY = 'flocks_layout_sidebar_width'; const SIDEBAR_DEFAULT_WIDTH = 208; const SIDEBAR_MIN_WIDTH = 176; @@ -80,6 +94,33 @@ function saveCollapsedNavSectionIds(sectionIds: Set): void { } } +/** + * The AI workbench and each scene workspace (SOC) form an accordion: at most + * one of them shows its second-level menu at a time. `undefined` means nothing + * has been stored yet, `null` means the user collapsed all of them. + */ +function readExpandedPrimaryNavSectionId(): string | null | undefined { + try { + const rawValue = localStorage.getItem(EXPANDED_PRIMARY_NAV_SECTION_KEY); + if (rawValue === null) return undefined; + return rawValue === '' ? null : rawValue; + } catch { + return undefined; + } +} + +function saveExpandedPrimaryNavSectionId(sectionId: string | null): void { + try { + localStorage.setItem(EXPANDED_PRIMARY_NAV_SECTION_KEY, sectionId ?? ''); + } catch { + // Local storage can be unavailable in restricted browser contexts. + } +} + +function workspaceNavSectionId(workspaceId: string): string { + return `${WORKSPACE_NAV_SECTION_PREFIX}${workspaceId}`; +} + function clampSidebarWidth(width: number): number { if (!Number.isFinite(width)) return SIDEBAR_DEFAULT_WIDTH; return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width))); @@ -159,9 +200,16 @@ import { useWebUIContractPages } from '@/hooks/useWebUIContractPages'; import { preloadI18nNamespaces } from '@/i18nResources'; import { resolveWebUIContractPageIcon } from '@/utils/webuiContractPageIcons'; import { - buildWebUIContractWorkspaceSections, + buildWebUIContractWorkspacePageList, getLocalizedWebUIContractTitle, } from '@/utils/webuiContractWorkspaceSections'; +import { useWorkspacePageOrders } from '@/hooks/useWorkspacePageOrders'; +import { moveItem, saveNavItemOrder, saveWorkspacePageOrder } from '@/utils/workspaceNavOrder'; +import { useLayoutOpenTabs } from '@/hooks/useLayoutOpenTabs'; +import { findActiveTabHref, nextTabAfterClose, resolveOpenTabs } from '@/utils/layoutTabs'; +import OpenTabsBar, { type OpenTabsBarGroup, type OpenTabsBarTab } from './OpenTabsBar'; +import KeepAlivePanes from './KeepAlivePanes'; +import { contentRoutes as appContentRoutes } from '@/routes/contentRoutes'; import { sessionApi } from '@/api/session'; import { useToast } from '@/components/common/Toast'; import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; @@ -177,8 +225,8 @@ interface LayoutNavItem { name: string; href: string; icon: LucideIcon; - opensWorkspaceMenu?: boolean; - workspaceId?: string; + /** Workspace page id; present for reorderable workspace pages. */ + pageId?: string; } interface LayoutNavSection { @@ -186,6 +234,10 @@ interface LayoutNavSection { name: string; items: LayoutNavItem[]; collapsible?: boolean; + /** Sections in the same accordion group expand one at a time. */ + accordionGroup?: 'primary'; + /** Set for sections that mirror a WebUI contract workspace (e.g. SOC). */ + workspace?: WebUIContractWorkspaceListItem; } function formatProVersion(version?: string | null): string | null { @@ -245,7 +297,12 @@ function isSocWorkspace(workspace: WebUIContractWorkspaceListItem | null): boole || workspace.title.includes('SOC 工作区'); } -export default function Layout() { +interface LayoutProps { + /** Pages rendered inside the layout; defaults to the app routes (tests inject probes). */ + contentRoutes?: RouteObject[]; +} + +export default function Layout({ contentRoutes = appContentRoutes }: LayoutProps = {}) { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); @@ -284,14 +341,29 @@ export default function Layout() { const notificationGateReady = flocksproStatusReady && (!canManageUpdates || hasCompletedUpdateCheck); const canCreateWorkspaceCustomPage = user?.role === 'admin'; - const { pages: webuiContractPages, workspaces: webuiContractWorkspaces = [] } = useWebUIContractPages(); - const [openWorkspaceMenuId, setOpenWorkspaceMenuId] = useState(null); + const { + pages: webuiContractPages, + workspaces: webuiContractWorkspaces = [], + loading: webuiContractNavLoading, + } = useWebUIContractPages(); + const workspacePageOrders = useWorkspacePageOrders(); + const [openTabRecords, setOpenTabRecords] = useLayoutOpenTabs(); + // Tab whose close is in flight: React Router commits the navigation as a + // transition, so until the route changes it must not be re-added. + const [closingTabHref, setClosingTabHref] = useState(null); + const openTabRecordsRef = useRef(openTabRecords); + useEffect(() => { + openTabRecordsRef.current = openTabRecords; + }, [openTabRecords]); const [collapsedNavSectionIds, setCollapsedNavSectionIds] = useState>(readCollapsedNavSectionIds); - const [collapsedWorkspaceSectionIds, setCollapsedWorkspaceSectionIds] = useState>(() => new Set()); + const [expandedPrimaryNavSectionId, setExpandedPrimaryNavSectionId] = useState( + readExpandedPrimaryNavSectionId, + ); + const [draggingNavItem, setDraggingNavItem] = useState<{ sectionId: string; key: string } | null>(null); + const [dragOverNavItemKey, setDragOverNavItemKey] = useState(null); const [creatingWorkspaceCustomPageSession, setCreatingWorkspaceCustomPageSession] = useState(false); const [socTitleDialogOpen, setSocTitleDialogOpen] = useState(false); const [socTitleDraft, setSocTitleDraft] = useState(readSocDashboardTitle); - const workspaceMenuCloseTimerRef = useRef(null); const hasCustomDisplayName = Boolean(configuredDisplayName?.trim()); const expandedSidebarWidth = sidebarWidth; const sidebarOffsetStyle = { @@ -657,14 +729,34 @@ export default function Layout() { // every , contributing to perceptible navigation lag. const navigation = useMemo( () => { - const sceneWorkspaceItems = webuiContractWorkspaces - .filter((workspace) => workspace.enabled && (workspace.placement === 'sceneWorkspace' || workspace.placement === 'aiWorkbench')) + const enabledWorkspaces = webuiContractWorkspaces.filter((workspace) => workspace.enabled); + const workspacePageItems = (workspace: WebUIContractWorkspaceListItem): LayoutNavItem[] => ( + workspacePageOrders + .apply(workspace.id, buildWebUIContractWorkspacePageList(workspace, i18n.language)) + .map((page) => ({ + pageId: page.id, + name: page.title, + href: `${workspace.route}/${page.id}`, + icon: resolveWebUIContractPageIcon(page.icon), + })) + ); + const workbenchWorkspaceItems = enabledWorkspaces + .filter((workspace) => workspace.placement === 'aiWorkbench') + .flatMap(workspacePageItems); + // Built-in groups can be reordered by dragging too; the order is stored per group. + const navItemKey = (item: LayoutNavItem) => item.pageId ?? item.href; + const orderedGroup = (sectionId: string, items: LayoutNavItem[]) => workspacePageOrders.applyNav(sectionId, items, navItemKey); + // Scene workspaces (SOC today) are top-level menus next to the AI + // workbench; their pages form the second-level menu. + const sceneWorkspaceSections: LayoutNavSection[] = enabledWorkspaces + .filter((workspace) => workspace.placement === 'sceneWorkspace') .map((workspace) => ({ + id: workspaceNavSectionId(workspace.id), name: getLocalizedWebUIContractTitle(workspace, i18n.language), - href: workspace.route, - icon: resolveWebUIContractPageIcon(workspace.icon), - opensWorkspaceMenu: true, - workspaceId: workspace.id, + collapsible: true, + accordionGroup: 'primary', + workspace, + items: workspacePageItems(workspace), })); return [ @@ -682,50 +774,38 @@ export default function Layout() { ], }, { - id: 'aiWorkbench', + id: AI_WORKBENCH_NAV_SECTION_ID, name: t('aiWorkbench'), collapsible: true, - items: [ + accordionGroup: 'primary', + items: orderedGroup(AI_WORKBENCH_NAV_SECTION_ID, [ { name: t('sessions'), href: '/sessions', icon: MessageSquare }, { name: t('workspace'), href: '/workspace', icon: FolderOpen }, { name: t('tasks'), href: '/tasks', icon: ListTodo }, { name: t('workflows'), href: '/workflows', icon: Workflow }, - ], - }, - { - id: 'sceneWorkspaces', - name: t('sceneWorkspaces'), - collapsible: true, - items: [ - ...sceneWorkspaceItems, - { name: t('deviceIntegration'), href: '/devices', icon: ServerCog }, - ], + ...workbenchWorkspaceItems, + ]), }, + ...sceneWorkspaceSections, { id: 'agentHub', name: t('agentHub'), collapsible: true, - items: [ + items: orderedGroup('agentHub', [ { name: t('agents'), href: '/agents', icon: Bot }, { name: t('skills'), href: '/skills', icon: BookOpen }, { name: t('tools'), href: '/tools', icon: Wrench }, + { name: t('deviceIntegration'), href: '/devices', icon: ServerCog }, { name: t('hub', { productName }), href: '/hub', icon: Archive }, { name: t('models'), href: '/models', icon: Brain }, { name: t('channels'), href: '/channels', icon: Radio }, - ], + ]), }, ]; }, - [i18n.language, productName, webuiContractPages, webuiContractWorkspaces, t], + [i18n.language, productName, webuiContractPages, webuiContractWorkspaces, workspacePageOrders, t], ); - const isFullScreenPage = - matchPath('/workflows/create', location.pathname) || - matchPath('/workflows/:id/edit', location.pathname) || - matchPath('/workflows/:id', location.pathname) || - matchPath('/sessions', location.pathname) || - matchPath('/devices', location.pathname) || - matchPath('/contracts/webui/*', location.pathname); const displayVersion = isFlocksproActive ? updateInfo?.edition === 'flockspro' ? formatProVersion(currentProductVersion(updateInfo, true)) @@ -746,54 +826,136 @@ export default function Layout() { hash: location.hash, }, }; - const activeWorkspaceMenu = useMemo( - () => webuiContractWorkspaces.find((workspace) => workspace.id === openWorkspaceMenuId && workspace.enabled) ?? null, - [openWorkspaceMenuId, webuiContractWorkspaces], - ); - const ActiveWorkspaceMenuIcon = activeWorkspaceMenu - ? resolveWebUIContractPageIcon(activeWorkspaceMenu.icon) - : null; - const activeWorkspaceSections = useMemo( - () => (activeWorkspaceMenu ? buildWebUIContractWorkspaceSections(activeWorkspaceMenu, i18n.language) : []), - [activeWorkspaceMenu, i18n.language], + const isNavItemActive = useCallback((href: string) => ( + location.pathname === href || (href !== '/' && location.pathname.startsWith(`${href}/`)) + ), [location.pathname]); + + // Browser-style tabs above the content: every sidebar entry visited becomes + // a tab (home, AI workbench pages, agent studio pages, SOC pages alike). + const navItemsFlat = useMemo(() => navigation.flatMap((section) => section.items), [navigation]); + const activeTabHref = useMemo( + () => findActiveTabHref(navItemsFlat.map((item) => item.href), location.pathname), + [location.pathname, navItemsFlat], ); - const activeWorkspaceMenuTitle = activeWorkspaceMenu - ? getLocalizedWebUIContractTitle(activeWorkspaceMenu, i18n.language) - : ''; - const showWorkspaceCustomPageAction = canCreateWorkspaceCustomPage && isSocWorkspace(activeWorkspaceMenu); - const showSocDashboardTitleAction = isSocWorkspace(activeWorkspaceMenu); - - const cancelWorkspaceMenuClose = useCallback(() => { - if (workspaceMenuCloseTimerRef.current === null) return; - window.clearTimeout(workspaceMenuCloseTimerRef.current); - workspaceMenuCloseTimerRef.current = null; - }, []); - - const openWorkspaceMenu = useCallback((workspaceId?: string) => { - if (!workspaceId) return; - cancelWorkspaceMenuClose(); - setOpenWorkspaceMenuId(workspaceId); - }, [cancelWorkspaceMenuClose]); + const currentLocationPath = `${location.pathname}${location.search}`; - const scheduleWorkspaceMenuClose = useCallback(() => { - cancelWorkspaceMenuClose(); - workspaceMenuCloseTimerRef.current = window.setTimeout(() => { - setOpenWorkspaceMenuId(null); - workspaceMenuCloseTimerRef.current = null; - }, 120); - }, [cancelWorkspaceMenuClose]); + useEffect(() => { + if (!activeTabHref) return; + if (closingTabHref === activeTabHref) return; + if (closingTabHref !== null) setClosingTabHref(null); + const stored = openTabRecordsRef.current; + const existing = stored.find((record) => record.href === activeTabHref); + if (existing && existing.path === currentLocationPath) return; + setOpenTabRecords(existing + ? stored.map((record) => (record.href === activeTabHref ? { ...record, path: currentLocationPath } : record)) + : [...stored, { href: activeTabHref, path: currentLocationPath }]); + }, [activeTabHref, closingTabHref, currentLocationPath, setOpenTabRecords]); + + const openTabs = useMemo(() => { + const tabs: OpenTabsBarTab[] = resolveOpenTabs(navItemsFlat, openTabRecords) + .map((tab) => ({ href: tab.href, path: tab.path, name: tab.name, icon: tab.icon })); + if (activeTabHref && activeTabHref !== closingTabHref && !tabs.some((tab) => tab.href === activeTabHref)) { + const item = navItemsFlat.find((entry) => entry.href === activeTabHref); + if (item) tabs.push({ href: item.href, path: currentLocationPath, name: item.name, icon: item.icon }); + } + return tabs; + }, [activeTabHref, closingTabHref, currentLocationPath, navItemsFlat, openTabRecords]); + + const closedTabGroups = useMemo(() => { + const open = new Set(openTabs.map((tab) => tab.href)); + return navigation.map((section, index) => ({ + id: section.id ?? `section-${index}`, + name: section.name, + items: section.items + .filter((item) => !open.has(item.href)) + .map((item) => ({ href: item.href, name: item.name, icon: item.icon })), + })); + }, [navigation, openTabs]); + + const closeTab = useCallback((href: string) => { + const hrefs = openTabs.map((tab) => tab.href); + setOpenTabRecords(openTabs.filter((tab) => tab.href !== href).map((tab) => ({ href: tab.href, path: tab.path }))); + if (href !== activeTabHref) return; + setClosingTabHref(href); + const nextHref = nextTabAfterClose(hrefs, href); + const nextTab = nextHref ? openTabs.find((tab) => tab.href === nextHref) : undefined; + navigate(nextTab ? nextTab.path : '/', { replace: true }); + }, [activeTabHref, navigate, openTabs, setOpenTabRecords]); + + const openTab = useCallback((href: string) => { + setSidebarOpen(false); + navigate(href); + }, [navigate]); + + // One pane per open tab stays mounted; a route that is not a sidebar entry + // gets a transient pane that goes away when it is left. + const keepAlivePanes = useMemo(() => { + const panes = openTabs.map((tab) => ({ href: tab.href })); + return activeTabHref ? panes : [...panes, { href: TRANSIENT_PANE_HREF }]; + }, [activeTabHref, openTabs]); + const activePaneHref = activeTabHref ?? TRANSIENT_PANE_HREF; + + const reorderTabs = useCallback((fromHref: string, toHref: string) => { + const hrefs = openTabs.map((tab) => tab.href); + const fromIndex = hrefs.indexOf(fromHref); + const toIndex = hrefs.indexOf(toHref); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return; + setOpenTabRecords(moveItem(openTabs, fromIndex, toIndex).map((tab) => ({ href: tab.href, path: tab.path }))); + }, [openTabs, setOpenTabRecords]); + + const primaryNavSectionIds = useMemo( + () => navigation + .filter((section) => section.accordionGroup === 'primary' && section.id) + .map((section) => section.id as string), + [navigation], + ); - useEffect(() => () => cancelWorkspaceMenuClose(), [cancelWorkspaceMenuClose]); + // The accordion section that owns the current route (null for home, settings, agent studio...). + const routePrimaryNavSectionId = useMemo(() => { + const owner = navigation.find((section) => ( + section.accordionGroup === 'primary' + && section.id + && section.items.some((item) => isNavItemActive(item.href)) + )); + return owner?.id ?? null; + }, [isNavItemActive, navigation]); + + const effectiveExpandedPrimaryNavSectionId = useMemo(() => { + if (expandedPrimaryNavSectionId === null) return null; + if (expandedPrimaryNavSectionId !== undefined) { + if (primaryNavSectionIds.includes(expandedPrimaryNavSectionId)) return expandedPrimaryNavSectionId; + // A stored workspace section that has not loaded yet: keep everything + // collapsed instead of flashing the AI workbench open. + if (webuiContractNavLoading && expandedPrimaryNavSectionId.startsWith(WORKSPACE_NAV_SECTION_PREFIX)) return null; + } + return routePrimaryNavSectionId ?? AI_WORKBENCH_NAV_SECTION_ID; + }, [expandedPrimaryNavSectionId, primaryNavSectionIds, routePrimaryNavSectionId, webuiContractNavLoading]); + // Mirrors the explicit (stored) choice so the route effect below can compare + // against it without re-running on every render. + const explicitExpandedPrimaryNavSectionRef = useRef(expandedPrimaryNavSectionId); useEffect(() => { - setCollapsedWorkspaceSectionIds(new Set()); - }, [openWorkspaceMenuId]); + explicitExpandedPrimaryNavSectionRef.current = expandedPrimaryNavSectionId; + }, [expandedPrimaryNavSectionId]); + // Entering a route owned by one accordion section opens that section and + // closes the other: people work in either the AI workbench or a scene + // workspace such as SOC, rarely both at once. The choice is persisted so + // leaving for the home page keeps the last group open. useEffect(() => { - if (openWorkspaceMenuId && !activeWorkspaceMenu) { - setOpenWorkspaceMenuId(null); - } - }, [activeWorkspaceMenu, openWorkspaceMenuId]); + if (!routePrimaryNavSectionId) return; + if (explicitExpandedPrimaryNavSectionRef.current === routePrimaryNavSectionId) return; + explicitExpandedPrimaryNavSectionRef.current = routePrimaryNavSectionId; + setExpandedPrimaryNavSectionId(routePrimaryNavSectionId); + saveExpandedPrimaryNavSectionId(routePrimaryNavSectionId); + }, [routePrimaryNavSectionId]); + + const togglePrimaryNavSection = useCallback((sectionId: string) => { + const next = effectiveExpandedPrimaryNavSectionId === sectionId ? null : sectionId; + explicitExpandedPrimaryNavSectionRef.current = next; + setExpandedPrimaryNavSectionId(next); + saveExpandedPrimaryNavSectionId(next); + }, [effectiveExpandedPrimaryNavSectionId]); const toggleNavSection = useCallback((sectionId: string) => { setCollapsedNavSectionIds((current) => { @@ -808,34 +970,88 @@ export default function Layout() { }); }, []); - const toggleWorkspaceSection = useCallback((sectionId: string) => { - setCollapsedWorkspaceSectionIds((current) => { - const next = new Set(current); - if (next.has(sectionId)) { - next.delete(sectionId); - } else { - next.add(sectionId); - } - return next; - }); + // Second-level menu order is customisable in every group: workspace pages are + // keyed by page id, built-in entries by href, each stored per group. + const sectionItemKeys = (section: LayoutNavSection) => section.items.map((item) => item.pageId ?? item.href); + + const reorderSectionItems = useCallback((section: LayoutNavSection, fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const keys = sectionItemKeys(section); + const fromIndex = keys.indexOf(fromKey); + const toIndex = keys.indexOf(toKey); + if (fromIndex < 0 || toIndex < 0) return; + const nextKeys = moveItem(keys, fromIndex, toIndex); + if (section.workspace) { + saveWorkspacePageOrder(section.workspace.id, nextKeys); + } else if (section.id) { + saveNavItemOrder(section.id, nextKeys); + } }, []); - const handleCreateWorkspaceCustomPage = useCallback(async () => { - if (!activeWorkspaceMenu || creatingWorkspaceCustomPageSession) return; + const handleNavItemDragStart = useCallback((event: ReactDragEvent, sectionId: string, key: string) => { + setDraggingNavItem({ sectionId, key }); + setDragOverNavItemKey(null); + try { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', key); + } catch { + // Some environments restrict dataTransfer; dragging still works through state. + } + }, []); + + const handleNavItemDragOver = useCallback((event: ReactDragEvent, sectionId: string, key: string) => { + if (!draggingNavItem || draggingNavItem.sectionId !== sectionId) return; + event.preventDefault(); + try { + event.dataTransfer.dropEffect = 'move'; + } catch { + // Ignore dataTransfer restrictions. + } + if (dragOverNavItemKey !== key) { + setDragOverNavItemKey(key); + } + }, [dragOverNavItemKey, draggingNavItem]); + + const handleNavItemDrop = useCallback((event: ReactDragEvent, section: LayoutNavSection, key: string) => { + if (!draggingNavItem || draggingNavItem.sectionId !== (section.id ?? '')) return; + event.preventDefault(); + reorderSectionItems(section, draggingNavItem.key, key); + setDraggingNavItem(null); + setDragOverNavItemKey(null); + }, [draggingNavItem, reorderSectionItems]); + + const handleNavItemDragEnd = useCallback(() => { + setDraggingNavItem(null); + setDragOverNavItemKey(null); + }, []); + + // Keyboard alternative to dragging: Alt+ArrowUp / Alt+ArrowDown moves the focused entry. + const handleNavItemReorderKeyDown = useCallback((event: ReactKeyboardEvent, section: LayoutNavSection, key: string) => { + if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; + const keys = sectionItemKeys(section); + const index = keys.indexOf(key); + const targetIndex = index + (event.key === 'ArrowUp' ? -1 : 1); + if (index < 0 || targetIndex < 0 || targetIndex >= keys.length) return; + event.preventDefault(); + reorderSectionItems(section, key, keys[targetIndex]); + }, [reorderSectionItems]); + + const handleCreateWorkspaceCustomPage = useCallback(async (workspace: WebUIContractWorkspaceListItem) => { + if (creatingWorkspaceCustomPageSession) return; + const workspaceTitle = getLocalizedWebUIContractTitle(workspace, i18n.language); setCreatingWorkspaceCustomPageSession(true); try { const session = await sessionApi.create({ title: tWebUIContractPage('workspace.customPageSessionTitle', { - workspace: activeWorkspaceMenuTitle, + workspace: workspaceTitle, }), }); const message = tWebUIContractPage('workspace.socCustomPageInitialMessage', { - workspaceId: activeWorkspaceMenu.id, - workspaceTitle: activeWorkspaceMenuTitle, - workspaceRoute: activeWorkspaceMenu.route, + workspaceId: workspace.id, + workspaceTitle, + workspaceRoute: workspace.route, }); const displayLabel = tWebUIContractPage('workspace.socCustomPageDisplayLabel'); - setOpenWorkspaceMenuId(null); setSidebarOpen(false); navigate( `/sessions?session=${session.id}&message=${encodeURIComponent(message)}&display=${encodeURIComponent(displayLabel)}`, @@ -847,9 +1063,8 @@ export default function Layout() { setCreatingWorkspaceCustomPageSession(false); } }, [ - activeWorkspaceMenu, - activeWorkspaceMenuTitle, creatingWorkspaceCustomPageSession, + i18n.language, navigate, tWebUIContractPage, toast, @@ -858,7 +1073,6 @@ export default function Layout() { const openSocTitleDialog = useCallback(() => { setSocTitleDraft(readSocDashboardTitle()); setSocTitleDialogOpen(true); - setOpenWorkspaceMenuId(null); }, []); const closeSocTitleDialog = useCallback(() => { @@ -1052,7 +1266,17 @@ export default function Layout() { {navigation.map((section, sectionIndex) => { const sectionId = (section.id ?? section.name) || `section-${sectionIndex}`; const sectionContentId = `layout-nav-section-${sectionId}`; - const sectionCollapsed = Boolean(section.collapsible && collapsedNavSectionIds.has(sectionId)); + const isPrimarySection = section.accordionGroup === 'primary'; + // In icon-only mode there are no labels to declutter, so every + // section stays reachable regardless of the accordion state. + const sectionCollapsed = !collapsed && Boolean(section.collapsible) && ( + isPrimarySection + ? effectiveExpandedPrimaryNavSectionId !== sectionId + : collapsedNavSectionIds.has(sectionId) + ); + const sectionWorkspace = section.workspace ?? null; + const reorderable = Boolean(section.collapsible) && section.items.length > 1; + const showSocWorkspaceActions = !collapsed && isSocWorkspace(sectionWorkspace); return (
{!collapsed && section.name && ( @@ -1060,7 +1284,7 @@ export default function Layout() { {section.collapsible ? ( + )} + +
+ )} )} @@ -1286,152 +1541,11 @@ export default function Layout() { - {activeWorkspaceMenu && ( - - )} - {/* Mobile top menu button */} -
+
@@ -1442,16 +1556,21 @@ export default function Layout() { className={`flex flex-col h-screen ${resizingSidebar ? '' : 'transition-all duration-300'} ${collapsed ? 'lg:pl-16' : 'lg:pl-[var(--layout-sidebar-width)]'}`} style={sidebarOffsetStyle} > -
- {isFullScreenPage ? ( - - ) : ( -
-
- -
-
- )} + +
+
diff --git a/webui/src/components/layout/OpenTabsBar.tsx b/webui/src/components/layout/OpenTabsBar.tsx new file mode 100644 index 000000000..07afaf75c --- /dev/null +++ b/webui/src/components/layout/OpenTabsBar.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from 'react'; +import type { DragEvent as ReactDragEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { Plus, X, type LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export interface OpenTabsBarTab { + href: string; + /** Location to open when the tab is activated (last visited path inside it). */ + path: string; + name: string; + icon?: LucideIcon; +} + +export interface OpenTabsBarGroup { + id: string; + name: string; + items: Array<{ href: string; name: string; icon?: LucideIcon }>; +} + +interface OpenTabsBarProps { + tabs: OpenTabsBarTab[]; + activeHref: string | null; + /** Sidebar entries that are not open yet, grouped like the sidebar. */ + closedGroups: OpenTabsBarGroup[]; + onClose: (href: string) => void; + onOpen: (href: string) => void; + /** Drag one tab onto another to move it there (browser-style reordering). */ + onReorder?: (fromHref: string, toHref: string) => void; +} + +/** + * Browser-style tab strip above the page content: one tab per visited sidebar + * entry, closable and draggable, with a "+" menu listing the entries that are + * not open yet. + */ +export default function OpenTabsBar({ tabs, activeHref, closedGroups, onClose, onOpen, onReorder }: OpenTabsBarProps) { + const { t } = useTranslation('nav'); + const [menuOpen, setMenuOpen] = useState(false); + const [draggingHref, setDraggingHref] = useState(null); + const [dragOverHref, setDragOverHref] = useState(null); + const menuRef = useRef(null); + const activeTabRef = useRef(null); + const hasClosedItems = closedGroups.some((group) => group.items.length > 0); + const reorderable = Boolean(onReorder) && tabs.length > 1; + + useEffect(() => { + if (!menuOpen) return undefined; + const handlePointerDown = (event: PointerEvent) => { + if (menuRef.current?.contains(event.target as Node)) return; + setMenuOpen(false); + }; + document.addEventListener('pointerdown', handlePointerDown); + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [menuOpen]); + + // The strip scrolls on narrow screens; keep the active tab in view. + useEffect(() => { + activeTabRef.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, [activeHref, tabs.length]); + + const handleDragStart = (event: ReactDragEvent, href: string) => { + setDraggingHref(href); + setDragOverHref(null); + try { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', href); + } catch { + // Some environments restrict dataTransfer; dragging still works through state. + } + }; + + const handleDragOver = (event: ReactDragEvent, href: string) => { + if (!draggingHref || draggingHref === href) return; + event.preventDefault(); + try { + event.dataTransfer.dropEffect = 'move'; + } catch { + // Ignore dataTransfer restrictions. + } + if (dragOverHref !== href) setDragOverHref(href); + }; + + const handleDrop = (event: ReactDragEvent, href: string) => { + if (!draggingHref) return; + event.preventDefault(); + if (draggingHref !== href) onReorder?.(draggingHref, href); + setDraggingHref(null); + setDragOverHref(null); + }; + + const handleDragEnd = () => { + setDraggingHref(null); + setDragOverHref(null); + }; + + return ( +
+
+ {tabs.map((tab) => { + const active = tab.href === activeHref; + const Icon = tab.icon; + const isDragging = draggingHref === tab.href; + const isDropTarget = dragOverHref === tab.href && draggingHref !== null && !isDragging; + return ( +
handleDragStart(event, tab.href) : undefined} + onDragOver={reorderable ? (event) => handleDragOver(event, tab.href) : undefined} + onDragLeave={reorderable ? () => { if (dragOverHref === tab.href) setDragOverHref(null); } : undefined} + onDrop={reorderable ? (event) => handleDrop(event, tab.href) : undefined} + onDragEnd={reorderable ? handleDragEnd : undefined} + className={`group relative flex h-9 max-w-[220px] shrink-0 items-center rounded-t-lg border border-b-0 transition-colors ${ + active + ? '-mb-px border-zinc-200 bg-gray-50 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50' + : 'border-transparent text-zinc-500 hover:bg-white/70 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100' + } ${reorderable ? 'cursor-grab active:cursor-grabbing' : ''} ${isDragging ? 'opacity-50' : ''} ${isDropTarget ? 'ring-2 ring-inset ring-zinc-400/70 dark:ring-zinc-500/70' : ''}`} + > + + {Icon ? : null} + {tab.name} + + +
+ ); + })} +
+ {/* The "+" lives outside the scrolling strip so its menu is never clipped. */} +
+ + {menuOpen && ( +
+ {!hasClosedItems ? ( +
{t('allPagesOpen')}
+ ) : ( + closedGroups.filter((group) => group.items.length > 0).map((group) => ( +
+ {group.name ? ( +
+ {group.name} +
+ ) : null} + {group.items.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+ )) + )} +
+ )} +
+
+ ); +} diff --git a/webui/src/hooks/useLayoutOpenTabs.ts b/webui/src/hooks/useLayoutOpenTabs.ts new file mode 100644 index 000000000..4835aa356 --- /dev/null +++ b/webui/src/hooks/useLayoutOpenTabs.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + LAYOUT_OPEN_TABS_CHANGED_EVENT, + readOpenTabs, + saveOpenTabs, + type OpenTabRecord, +} from '@/utils/layoutTabs'; + +/** Open-tab records for the main layout, kept in sync with localStorage. */ +export function useLayoutOpenTabs(): [OpenTabRecord[], (records: OpenTabRecord[]) => void] { + const [records, setRecords] = useState(readOpenTabs); + + useEffect(() => { + const handleChange = () => setRecords(readOpenTabs()); + window.addEventListener(LAYOUT_OPEN_TABS_CHANGED_EVENT, handleChange); + return () => window.removeEventListener(LAYOUT_OPEN_TABS_CHANGED_EVENT, handleChange); + }, []); + + const update = useCallback((next: OpenTabRecord[]) => { + setRecords(next); + saveOpenTabs(next); + }, []); + + return [records, update]; +} diff --git a/webui/src/hooks/useWorkspacePageOrders.ts b/webui/src/hooks/useWorkspacePageOrders.ts new file mode 100644 index 000000000..67c1fa1bd --- /dev/null +++ b/webui/src/hooks/useWorkspacePageOrders.ts @@ -0,0 +1,42 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + applyOrderByKey, + applyWorkspacePageOrder, + readNavItemOrder, + readWorkspacePageOrder, + WORKSPACE_PAGE_ORDER_CHANGED_EVENT, +} from '@/utils/workspaceNavOrder'; + +export interface WorkspacePageOrders { + /** Increments whenever an order is saved in this tab. */ + version: number; + read: (workspaceId: string) => string[]; + apply: (workspaceId: string, pages: T[]) => T[]; + /** Same for built-in sidebar entries of one group, keyed by `keyOf(item)`. */ + applyNav: (scopeId: string, items: T[], keyOf: (item: T) => string) => T[]; +} + +/** + * Exposes the per-browser workspace page order and re-renders callers when a + * new order is saved (for example after dragging a SOC page in the sidebar). + */ +export function useWorkspacePageOrders(): WorkspacePageOrders { + const [version, setVersion] = useState(0); + + useEffect(() => { + const handleChange = () => setVersion((current) => current + 1); + window.addEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, handleChange); + return () => window.removeEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, handleChange); + }, []); + + return useMemo(() => ({ + version, + read: (workspaceId: string) => readWorkspacePageOrder(workspaceId), + apply: (workspaceId: string, pages: T[]) => ( + applyWorkspacePageOrder(pages, readWorkspacePageOrder(workspaceId)) + ), + applyNav: (scopeId: string, items: T[], keyOf: (item: T) => string) => ( + applyOrderByKey(items, keyOf, readNavItemOrder(scopeId)) + ), + }), [version]); +} diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index efa5eb51b..ffa4b6a98 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -2,7 +2,6 @@ "home": "Home", "flocksHome": "Home", "aiWorkbench": "AI Workbench", - "sceneWorkspaces": "Scene Workspaces", "sessions": "Workbench", "tasks": "Task Center", "workspace": "File Directory", @@ -66,6 +65,10 @@ "expandNav": "Expand navigation", "collapseNav": "Collapse navigation", "resizeNav": "Resize navigation", + "openTabs": "Open pages", + "closeTab": "Close {{title}}", + "openPage": "Open a page", + "allPagesOpen": "All pages are open", "switchLanguage": "Switch Language", "switchToDarkTheme": "Switch to dark mode", "switchToLightTheme": "Switch to light mode", diff --git a/webui/src/locales/en-US/webuiContractPage.json b/webui/src/locales/en-US/webuiContractPage.json index b927794b5..9fcbb132c 100644 --- a/webui/src/locales/en-US/webuiContractPage.json +++ b/webui/src/locales/en-US/webuiContractPage.json @@ -22,11 +22,7 @@ "loadFailed": "Failed to load workspace", "notFound": "Workspace not found", "empty": "No pages in this workspace", - "selectPage": "Select a page from the workspace menu", "pageNotFound": "Page not found", - "sectionNavigation": "Workspace page sections", - "collapseSidebar": "Collapse sidebar", - "expandSidebar": "Expand sidebar", "customPage": "Custom Page", "customTitle": "Custom Title", "customTitleDialogTitle": "Customize posture page title", diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index e6a955690..0014c1302 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -2,7 +2,6 @@ "home": "首页", "flocksHome": "首页", "aiWorkbench": "AI 工作台", - "sceneWorkspaces": "场景工作区", "sessions": "工作台", "tasks": "任务中心", "workspace": "文件目录", @@ -66,6 +65,10 @@ "expandNav": "展开导航", "collapseNav": "收起导航", "resizeNav": "调整导航宽度", + "openTabs": "已打开的页面", + "closeTab": "关闭 {{title}}", + "openPage": "打开页面", + "allPagesOpen": "所有页面都已打开", "switchLanguage": "切换语言", "switchToDarkTheme": "切换到深色模式", "switchToLightTheme": "切换到浅色模式", diff --git a/webui/src/locales/zh-CN/webuiContractPage.json b/webui/src/locales/zh-CN/webuiContractPage.json index 6be57f46c..9f39bfcb5 100644 --- a/webui/src/locales/zh-CN/webuiContractPage.json +++ b/webui/src/locales/zh-CN/webuiContractPage.json @@ -22,11 +22,7 @@ "loadFailed": "加载工作区失败", "notFound": "工作区不存在", "empty": "工作区暂无页面", - "selectPage": "请从工作区菜单选择页面", "pageNotFound": "页面不存在", - "sectionNavigation": "工作区页面分类", - "collapseSidebar": "折叠侧边栏", - "expandSidebar": "展开侧边栏", "customPage": "自定义页面", "customTitle": "自定义标题", "customTitleDialogTitle": "自定义态势页标题", diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx index 926803175..d49244aad 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx @@ -26,11 +26,22 @@ vi.mock('react-i18next', () => ({ }), })); +function renderHostAt(path: string) { + return render( + + + } /> + + , + ); +} + describe('WebUIContractWorkspaceHost', () => { setupSSEMock(); beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); listWorkspacesMock.mockResolvedValue({ data: [ { @@ -108,20 +119,66 @@ describe('WebUIContractWorkspaceHost', () => { }); }); - it('waits for an explicit page selection on the workspace root', async () => { - render( - - - } /> - - , - ); + it('redirects the workspace root to its default page', async () => { + renderHostAt('/contracts/webui/workspaces/scene_workspace'); await waitFor(() => { - expect(screen.getByText('workspace.selectPage')).toBeInTheDocument(); + expect(screen.getByText('page:ops-overview')).toBeInTheDocument(); + }); + expect(screen.queryByText('workspace.selectPage')).not.toBeInTheDocument(); + }); + + it('falls back to the first ready page when the default page id is unknown', async () => { + listWorkspacesMock.mockResolvedValue({ + data: [ + { + id: 'scene_workspace', + title: '场景工作区', + route: '/contracts/webui/workspaces/scene_workspace', + icon: 'ShieldCheck', + order: 10, + enabled: true, + placement: 'sceneWorkspace', + defaultPageId: 'missing-page', + sections: [], + pages: [ + { + id: 'investigation-list', + title: '调查列表', + route: '/contracts/webui/investigation-list', + icon: 'AlertTriangle', + order: 5, + enabled: true, + placement: 'home.after', + buildHash: '', + buildStatus: 'failed', + workspaceId: 'scene_workspace', + workspaceTitle: '场景工作区', + workspaceRoute: '/contracts/webui/workspaces/scene_workspace', + }, + { + id: 'ops-overview', + title: '运营总览', + route: '/contracts/webui/ops-overview', + icon: 'Shield', + order: 10, + enabled: true, + placement: 'home.after', + buildHash: 'abc', + buildStatus: 'ready', + workspaceId: 'scene_workspace', + workspaceTitle: '场景工作区', + workspaceRoute: '/contracts/webui/workspaces/scene_workspace', + }, + ], + }, + ], + }); + renderHostAt('/contracts/webui/workspaces/scene_workspace'); + + await waitFor(() => { + expect(screen.getByText('page:ops-overview')).toBeInTheDocument(); }); - expect(screen.queryByText('page:risk-dashboard')).not.toBeInTheDocument(); - expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); }); it('renders a selected operation page without a fixed workspace sidebar', async () => { @@ -137,6 +194,7 @@ describe('WebUIContractWorkspaceHost', () => { expect(screen.getByText('page:investigation-list')).toBeInTheDocument(); }); expect(screen.getByText('page:investigation-list').parentElement).toHaveClass('p-6'); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); }); diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx index cee5d4c5d..bd619657d 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx @@ -1,5 +1,5 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertCircle, Loader2 } from 'lucide-react'; import { @@ -8,9 +8,13 @@ import { } from '@/api/webuiContractPages'; import { useSSE } from '@/hooks/useSSE'; import { useDelayedVisible } from '@/hooks/useDelayedVisible'; +import { useWorkspacePageOrders } from '@/hooks/useWorkspacePageOrders'; import { ThemeContext } from '@/contexts/ThemeContext'; import PageRuntimeHost from '@/pages/WebUIContractPageHost/PageRuntimeHost'; -import { buildWebUIContractWorkspaceSections } from '@/utils/webuiContractWorkspaceSections'; +import { + buildWebUIContractWorkspacePageList, + buildWebUIContractWorkspaceSections, +} from '@/utils/webuiContractWorkspaceSections'; export default function WebUIContractWorkspaceHost() { const { workspaceId, pageId } = useParams<{ workspaceId: string; pageId?: string }>(); @@ -20,6 +24,12 @@ export default function WebUIContractWorkspaceHost() { const [error, setError] = useState(null); const showLoading = useDelayedVisible(loading ? 180 : 0); const { theme, setTemporaryThemeOverride } = useContext(ThemeContext); + const workspacePageOrders = useWorkspacePageOrders(); + // Keep the fetcher stable: a new `t` identity must not trigger a refetch loop. + const tRef = useRef(t); + useEffect(() => { + tRef.current = t; + }, [t]); const fetchWorkspaces = useCallback(async (silent = false) => { if (!silent) setLoading(true); @@ -29,11 +39,11 @@ export default function WebUIContractWorkspaceHost() { setWorkspaces(Array.isArray(response.data) ? response.data : []); } catch (err: unknown) { setWorkspaces([]); - setError(err instanceof Error ? err.message : t('workspace.loadFailed')); + setError(err instanceof Error ? err.message : tRef.current('workspace.loadFailed')); } finally { if (!silent) setLoading(false); } - }, [t]); + }, []); useEffect(() => { void fetchWorkspaces(); @@ -53,14 +63,17 @@ export default function WebUIContractWorkspaceHost() { () => workspaces.find((item) => item.id === workspaceId), [workspaceId, workspaces], ); - const pages = useMemo( - () => [...(workspace?.pages ?? [])].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)), - [workspace?.pages], - ); const sections = useMemo( () => (workspace ? buildWebUIContractWorkspaceSections(workspace, i18n.language) : []), [i18n.language, workspace], ); + // Same sequence as the sidebar menu: section order, then the user's drag order. + const pages = useMemo( + () => (workspace + ? workspacePageOrders.apply(workspace.id, buildWebUIContractWorkspacePageList(workspace, i18n.language)) + : []), + [i18n.language, workspace, workspacePageOrders], + ); const currentPage = pages.find((page) => page.id === pageId); const currentSection = currentPage ? sections.find((section) => section.pages.some((page) => page.id === currentPage.id)) @@ -121,11 +134,11 @@ export default function WebUIContractWorkspaceHost() { } if (!pageId) { - return ( -
- {t('workspace.selectPage')} -
- ); + // Opening the workspace itself lands on its default page instead of an empty frame. + const defaultPage = pages.find((page) => page.id === workspace.defaultPageId) + ?? pages.find((page) => page.buildStatus === 'ready') + ?? pages[0]; + return ; } if (!currentPage) { diff --git a/webui/src/routes/contentRoutes.tsx b/webui/src/routes/contentRoutes.tsx new file mode 100644 index 000000000..2b927aa3c --- /dev/null +++ b/webui/src/routes/contentRoutes.tsx @@ -0,0 +1,143 @@ +import { Suspense, lazy } from 'react'; +import type { ComponentType, ReactNode } from 'react'; +import { Navigate, useLocation, useParams, type RouteObject } from 'react-router-dom'; +import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; +import RoutePageSkeleton from '@/components/common/RoutePageSkeleton'; +import Home from '@/pages/Home'; +import { useAuth } from '@/contexts/AuthContext'; +import { preloadI18nNamespaces } from '@/i18nResources'; +import { recoverLazyLoad } from '@/utils/chunkLoadRecovery'; + +// All non-Home pages are code-split. Home stays eager because it's the very +// first frame after auth and we don't want a Suspense flash on initial paint. +// In particular, Session/Agent and the auth screens are kept lazy so heavy +// transitive deps (SessionChat ~2.7k LOC + react-markdown + rehype/remark + +// highlight.js) are not pulled into the main entry chunk. +type LazyPageModule = { default: ComponentType }; + +export function lazyPage( + loader: () => Promise, + namespaces: readonly string[] = [], +) { + return lazy(() => recoverLazyLoad( + Promise.all([ + loader(), + preloadI18nNamespaces(namespaces), + ]).then(([module]) => module), + )); +} + +const SessionPage = lazyPage(() => import('@/pages/Session'), ['session']); +const AgentPage = lazyPage(() => import('@/pages/Agent'), ['agent']); +export const LoginPage = lazyPage(() => import('@/pages/Login')); +export const SetupAdminPage = lazyPage(() => import('@/pages/SetupAdmin')); +export const ForceChangePasswordPage = lazyPage(() => import('@/pages/ForceChangePassword')); // secret-guard: allow (component name, not a credential) +const WorkflowListPage = lazyPage(() => import('@/pages/Workflow'), ['workflow']); +const WorkflowCreate = lazyPage(() => import('@/pages/WorkflowCreate'), ['workflow']); +const WorkflowEditor = lazyPage(() => import('@/pages/WorkflowEditor'), ['workflow']); +const WorkflowDetail = lazyPage(() => import('@/pages/WorkflowDetail'), ['workflow']); +const TaskPage = lazyPage(() => import('@/pages/Task'), ['task']); +const ToolPage = lazyPage(() => import('@/pages/Tool'), ['tool']); +const HubPage = lazyPage(() => import('@/pages/Hub')); +const SkillPage = lazyPage(() => import('@/pages/Skill'), ['skill']); +const ModelPage = lazyPage(() => import('@/pages/Model'), ['model']); +const ChannelPage = lazyPage(() => import('@/pages/Channel'), ['channel']); +const PermissionPage = lazyPage(() => import('@/pages/Permission'), ['permission']); +const MonitoringPage = lazyPage(() => import('@/pages/Monitoring'), ['monitoring']); +const WorkspacePage = lazyPage(() => import('@/pages/Workspace'), ['workspace']); +const DeviceIntegrationPage = lazyPage(() => import('@/pages/DeviceIntegration'), ['device']); +const FlocksproUpgradeCallbackPage = lazyPage(() => import('@/pages/FlocksproUpgrade/Callback'), ['flockspro']); +export const SettingsPage = lazyPage(() => import('@/pages/Settings')); +const WebUIContractPageHost = lazyPage(() => import('@/pages/WebUIContractPageHost')); +const WebUIContractWorkspaceHost = lazyPage(() => import('@/pages/WebUIContractWorkspaceHost')); +const ROUTE_FALLBACK_DELAY_MS = 180; + +export function LazyRoute({ children }: { children: ReactNode }) { + return ( + + }> + {children} + + + ); +} + +function AdminOnlyRoute({ children }: { children: ReactNode }) { + const { user } = useAuth(); + if (user?.role !== 'admin') { + return ; + } + return <>{children}; +} + +export function LegacyWebUIContractPageRedirect() { + const params = useParams(); + const location = useLocation(); + const pageId = params.pageId; + const rest = params['*']; + if (!pageId) return ; + return ( + + ); +} + +/** + * Pages shown inside the main layout. The layout keeps one pane alive per open + * tab and matches these routes against each tab's own location, so the list + * lives here instead of being nested under the layout route. + */ +export const contentRoutes: RouteObject[] = [ + { index: true, element: }, + { path: 'contracts/webui/workspaces/:workspaceId/:pageId?', element: }, + { path: 'contracts/webui/:pageId/*', element: }, + { path: 'user-defined-pages/:pageId/*', element: }, + + // AI 工作台 + { path: 'sessions', element: }, + { path: 'agents', element: }, + { path: 'workflows', element: }, + { path: 'workflows/new', element: }, + { path: 'workflows/:id', element: }, + { path: 'workflows/:id/edit', element: }, + { path: 'tasks', element: }, + { path: 'workspace', element: }, + + // 设备接入 + { path: 'devices', element: }, + + // Agent Smith + { path: 'tools', element: }, + { path: 'hub', element: }, + { path: 'models', element: }, + { path: 'skills', element: }, + // MCP 已整合到工具清单页面 + { path: 'mcp', element: }, + + { path: 'config', element: }, + { path: 'config/*', element: }, + { path: 'system-logs', element: }, + { path: 'channels', element: }, + { path: 'permissions', element: }, + { path: 'monitoring', element: }, + { path: 'audit-logs', element: }, + { path: 'admin/users', element: }, + { path: 'flockspro-upgrade', element: }, + { + path: 'flockspro-upgrade/callback', + element: , + }, + { path: '*', element: }, +]; + +/** Pages that manage their own scrolling and fill the content area edge to edge. */ +export const FULL_SCREEN_PATH_PATTERNS = [ + '/workflows/create', + '/workflows/:id/edit', + '/workflows/:id', + '/sessions', + '/devices', + '/contracts/webui/*', +]; diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx index 72e3113b1..48a878eb2 100644 --- a/webui/src/routes/index.tsx +++ b/webui/src/routes/index.tsx @@ -1,93 +1,23 @@ -import { Suspense, lazy } from 'react'; -import type { ComponentType, ReactNode } from 'react'; -import { Routes as RouterRoutes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; +import { Suspense } from 'react'; +import { Routes as RouterRoutes, Route, Navigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Layout from '@/components/layout/Layout'; import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; import RoutePageSkeleton from '@/components/common/RoutePageSkeleton'; import AuthLayout from '@/components/layout/AuthLayout'; -import Home from '@/pages/Home'; import { useAuth } from '@/contexts/AuthContext'; -import { preloadI18nNamespaces } from '@/i18nResources'; -import { installVitePreloadErrorRecovery, recoverLazyLoad } from '@/utils/chunkLoadRecovery'; +import { installVitePreloadErrorRecovery } from '@/utils/chunkLoadRecovery'; +import { + ForceChangePasswordPage, + LazyRoute, + LoginPage, + SettingsPage, + SetupAdminPage, +} from './contentRoutes'; -installVitePreloadErrorRecovery(); - -// All non-Home pages are code-split. Home stays eager because it's the very -// first frame after auth and we don't want a Suspense flash on initial paint. -// In particular, Session/Agent and the auth screens are kept lazy so heavy -// transitive deps (SessionChat ~2.7k LOC + react-markdown + rehype/remark + -// highlight.js) are not pulled into the main entry chunk. -type LazyPageModule = { default: ComponentType }; - -function lazyPage( - loader: () => Promise, - namespaces: readonly string[] = [], -) { - return lazy(() => recoverLazyLoad( - Promise.all([ - loader(), - preloadI18nNamespaces(namespaces), - ]).then(([module]) => module), - )); -} - -const SessionPage = lazyPage(() => import('@/pages/Session'), ['session']); -const AgentPage = lazyPage(() => import('@/pages/Agent'), ['agent']); -const LoginPage = lazyPage(() => import('@/pages/Login')); -const SetupAdminPage = lazyPage(() => import('@/pages/SetupAdmin')); -const ForceChangePasswordPage = lazyPage(() => import('@/pages/ForceChangePassword')); -const WorkflowListPage = lazyPage(() => import('@/pages/Workflow'), ['workflow']); -const WorkflowCreate = lazyPage(() => import('@/pages/WorkflowCreate'), ['workflow']); -const WorkflowEditor = lazyPage(() => import('@/pages/WorkflowEditor'), ['workflow']); -const WorkflowDetail = lazyPage(() => import('@/pages/WorkflowDetail'), ['workflow']); -const TaskPage = lazyPage(() => import('@/pages/Task'), ['task']); -const ToolPage = lazyPage(() => import('@/pages/Tool'), ['tool']); -const HubPage = lazyPage(() => import('@/pages/Hub')); -const SkillPage = lazyPage(() => import('@/pages/Skill'), ['skill']); -const ModelPage = lazyPage(() => import('@/pages/Model'), ['model']); -const ChannelPage = lazyPage(() => import('@/pages/Channel'), ['channel']); -const PermissionPage = lazyPage(() => import('@/pages/Permission'), ['permission']); -const MonitoringPage = lazyPage(() => import('@/pages/Monitoring'), ['monitoring']); -const WorkspacePage = lazyPage(() => import('@/pages/Workspace'), ['workspace']); -const DeviceIntegrationPage = lazyPage(() => import('@/pages/DeviceIntegration'), ['device']); -const FlocksproUpgradeCallbackPage = lazyPage(() => import('@/pages/FlocksproUpgrade/Callback'), ['flockspro']); -const SettingsPage = lazyPage(() => import('@/pages/Settings')); -const WebUIContractPageHost = lazyPage(() => import('@/pages/WebUIContractPageHost')); -const WebUIContractWorkspaceHost = lazyPage(() => import('@/pages/WebUIContractWorkspaceHost')); -const ROUTE_FALLBACK_DELAY_MS = 180; - -function LazyRoute({ children }: { children: ReactNode }) { - return ( - - }> - {children} - - - ); -} - -function AdminOnlyRoute({ children }: { children: React.ReactNode }) { - const { user } = useAuth(); - if (user?.role !== 'admin') { - return ; - } - return <>{children}; -} +export { LegacyWebUIContractPageRedirect } from './contentRoutes'; -export function LegacyWebUIContractPageRedirect() { - const params = useParams(); - const location = useLocation(); - const pageId = params.pageId; - const rest = params['*']; - if (!pageId) return ; - return ( - - ); -} +installVitePreloadErrorRecovery(); export function Routes() { const { t } = useTranslation('auth'); @@ -158,50 +88,9 @@ export function Routes() { } /> } /> } /> - }> - } /> - } /> - } /> - } /> - - {/* AI 工作台 */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* 设备接入 */} - } /> - - {/* Agent Smith */} - } /> - } /> - } /> - } /> - {/* MCP 已整合到工具清单页面 */} - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - + {/* Every in-app page renders inside the layout, which keeps one pane per + open tab alive; see routes/contentRoutes.tsx for the page list. */} + } /> } /> ); diff --git a/webui/src/utils/layoutTabs.test.ts b/webui/src/utils/layoutTabs.test.ts new file mode 100644 index 000000000..031723c71 --- /dev/null +++ b/webui/src/utils/layoutTabs.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + findActiveTabHref, + isPathWithinHref, + LAYOUT_OPEN_TABS_CHANGED_EVENT, + nextTabAfterClose, + readOpenTabs, + resolveOpenTabs, + saveOpenTabs, +} from './layoutTabs'; + +const items = [ + { href: '/', name: '首页' }, + { href: '/sessions', name: '工作台' }, + { href: '/workflows', name: '工作流' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', name: '告警调查' }, +]; + +describe('layoutTabs', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('matches sub-routes to their sidebar entry, home only exactly', () => { + expect(isPathWithinHref('/workflows/wf-1/edit', '/workflows')).toBe(true); + expect(isPathWithinHref('/workflowsx', '/workflows')).toBe(false); + expect(isPathWithinHref('/sessions', '/')).toBe(false); + expect(isPathWithinHref('/', '/')).toBe(true); + expect(findActiveTabHref(items.map((item) => item.href), '/workflows/wf-1')).toBe('/workflows'); + expect(findActiveTabHref(items.map((item) => item.href), '/settings/account')).toBeNull(); + }); + + it('resolves stored records in order, keeps their last path, drops unknown and duplicated hrefs', () => { + const tabs = resolveOpenTabs(items, [ + { href: '/workflows', path: '/workflows/wf-1' }, + { href: '/removed', path: '/removed' }, + { href: '/sessions', path: '/sessions?session=s1' }, + { href: '/workflows', path: '/workflows' }, + ]); + expect(tabs.map((tab) => [tab.href, tab.path])).toEqual([ + ['/workflows', '/workflows/wf-1'], + ['/sessions', '/sessions?session=s1'], + ]); + }); + + it('activates the right neighbour after closing, then the left one, then nothing', () => { + expect(nextTabAfterClose(['a', 'b', 'c'], 'b')).toBe('c'); + expect(nextTabAfterClose(['a', 'b', 'c'], 'c')).toBe('b'); + expect(nextTabAfterClose(['a'], 'a')).toBeNull(); + expect(nextTabAfterClose(['a', 'b'], 'zzz')).toBeNull(); + }); + + it('persists records and notifies listeners, tolerating bad stored data', () => { + const listener = vi.fn(); + window.addEventListener(LAYOUT_OPEN_TABS_CHANGED_EVENT, listener); + try { + saveOpenTabs([{ href: '/sessions', path: '/sessions' }]); + expect(readOpenTabs()).toEqual([{ href: '/sessions', path: '/sessions' }]); + expect(listener).toHaveBeenCalledTimes(1); + saveOpenTabs([]); + expect(localStorage.getItem('flocks_layout_open_tabs')).toBeNull(); + + localStorage.setItem('flocks_layout_open_tabs', JSON.stringify([{ href: '/a' }, { href: 3 }, 'x', null, { href: '/b', path: '' }])); + expect(readOpenTabs()).toEqual([{ href: '/a', path: '/a' }, { href: '/b', path: '/b' }]); + localStorage.setItem('flocks_layout_open_tabs', '{oops'); + expect(readOpenTabs()).toEqual([]); + } finally { + window.removeEventListener(LAYOUT_OPEN_TABS_CHANGED_EVENT, listener); + } + }); +}); diff --git a/webui/src/utils/layoutTabs.ts b/webui/src/utils/layoutTabs.ts new file mode 100644 index 000000000..2d37ec2b1 --- /dev/null +++ b/webui/src/utils/layoutTabs.ts @@ -0,0 +1,104 @@ +/** + * Browser-style open tabs for the main layout. + * + * Every sidebar entry the user visits (home, AI workbench pages, agent studio + * pages, SOC workspace pages, ...) becomes a tab in the bar above the content. + * Tabs keep their opening order, remember the last path visited inside them + * (a workflow detail page stays behind the "Workflows" tab), can be closed, and + * survive reloads through localStorage. + */ + +export interface OpenTabRecord { + /** Sidebar entry href, the tab's identity. */ + href: string; + /** Last visited location (pathname + search) inside this tab. */ + path: string; +} + +export const LAYOUT_OPEN_TABS_KEY = 'flocks_layout_open_tabs'; +export const LAYOUT_OPEN_TABS_CHANGED_EVENT = 'flocks:layout-open-tabs-changed'; + +export function readOpenTabs(): OpenTabRecord[] { + try { + const rawValue = localStorage.getItem(LAYOUT_OPEN_TABS_KEY); + if (!rawValue) return []; + const parsedValue: unknown = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) return []; + const records: OpenTabRecord[] = []; + for (const item of parsedValue) { + if (!item || typeof item !== 'object') continue; + const { href, path } = item as { href?: unknown; path?: unknown }; + if (typeof href !== 'string' || !href) continue; + records.push({ href, path: typeof path === 'string' && path ? path : href }); + } + return records; + } catch { + return []; + } +} + +export function saveOpenTabs(records: OpenTabRecord[]): void { + try { + if (records.length === 0) { + localStorage.removeItem(LAYOUT_OPEN_TABS_KEY); + } else { + localStorage.setItem(LAYOUT_OPEN_TABS_KEY, JSON.stringify(records)); + } + } catch { + // Local storage can be unavailable in restricted browser contexts. + } + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(LAYOUT_OPEN_TABS_CHANGED_EVENT)); + } +} + +/** + * Whether `pathname` lives under the sidebar entry `href`: exact match, or a + * sub-route of it. Home (`/`) only matches exactly. + */ +export function isPathWithinHref(pathname: string, href: string): boolean { + if (href === '/') return pathname === '/'; + return pathname === href || pathname.startsWith(`${href}/`); +} + +/** The sidebar entry that owns `pathname`; the longest matching href wins. */ +export function findActiveTabHref(hrefs: string[], pathname: string): string | null { + let best: string | null = null; + for (const href of hrefs) { + if (!isPathWithinHref(pathname, href)) continue; + if (best === null || href.length > best.length) best = href; + } + return best; +} + +/** + * Map stored records onto the current sidebar entries, keeping the stored + * order and dropping entries that no longer exist (plugin pages can go away). + */ +export function resolveOpenTabs( + items: T[], + records: OpenTabRecord[], +): Array { + const byHref = new Map(items.map((item) => [item.href, item])); + const seen = new Set(); + const tabs: Array = []; + for (const record of records) { + const item = byHref.get(record.href); + if (!item || seen.has(record.href)) continue; + seen.add(record.href); + tabs.push({ ...item, path: record.path }); + } + return tabs; +} + +/** + * Which tab becomes active after closing `closedHref`: the one to its right, + * otherwise the one to its left, otherwise nothing (browser behaviour). + */ +export function nextTabAfterClose(hrefs: string[], closedHref: string): string | null { + const index = hrefs.indexOf(closedHref); + if (index < 0) return null; + if (index + 1 < hrefs.length) return hrefs[index + 1]; + if (index - 1 >= 0) return hrefs[index - 1]; + return null; +} diff --git a/webui/src/utils/webuiContractWorkspaceSections.ts b/webui/src/utils/webuiContractWorkspaceSections.ts index cf42fd32a..c3257f49c 100644 --- a/webui/src/utils/webuiContractWorkspaceSections.ts +++ b/webui/src/utils/webuiContractWorkspaceSections.ts @@ -90,3 +90,24 @@ export function buildWebUIContractWorkspaceSections( }, ]; } + +/** + * Flatten a workspace into its pages in navigation order: section by section, + * each section's pages in their configured order. Used by the sidebar menu and + * the workspace page tabs so both show the same sequence. + */ +export function buildWebUIContractWorkspacePageList( + workspace: WebUIContractWorkspaceListItem, + language?: string | null, +): WebUIContractPageListItem[] { + const seen = new Set(); + const pages: WebUIContractPageListItem[] = []; + for (const section of buildWebUIContractWorkspaceSections(workspace, language)) { + for (const page of section.pages) { + if (seen.has(page.id)) continue; + seen.add(page.id); + pages.push(page); + } + } + return pages; +} diff --git a/webui/src/utils/workspaceNavOrder.test.ts b/webui/src/utils/workspaceNavOrder.test.ts new file mode 100644 index 000000000..9bc49d4df --- /dev/null +++ b/webui/src/utils/workspaceNavOrder.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + applyOrderByKey, + applyWorkspacePageOrder, + moveItem, + readNavItemOrder, + readWorkspacePageOrder, + saveNavItemOrder, + saveWorkspacePageOrder, + WORKSPACE_PAGE_ORDER_CHANGED_EVENT, +} from './workspaceNavOrder'; + +const pages = [ + { id: 'soc-dashboard', title: '态势' }, + { id: 'soc-overview', title: 'SOC 总览' }, + { id: 'soc-alerts', title: '告警调查' }, +]; + +describe('workspaceNavOrder', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('applies a stored order and appends pages that are not in it', () => { + const ordered = applyWorkspacePageOrder(pages, ['soc-alerts', 'soc-dashboard']); + expect(ordered.map((page) => page.id)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + }); + + it('ignores unknown and duplicated ids in the stored order', () => { + const ordered = applyWorkspacePageOrder(pages, ['removed-page', 'soc-overview', 'soc-overview']); + expect(ordered.map((page) => page.id)).toEqual(['soc-overview', 'soc-dashboard', 'soc-alerts']); + }); + + it('keeps the default order when nothing is stored', () => { + expect(applyWorkspacePageOrder(pages, [])).toBe(pages); + }); + + it('moves an item to a new index and rejects out-of-range moves', () => { + expect(moveItem(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a']); + expect(moveItem(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b']); + const original = ['a', 'b', 'c']; + expect(moveItem(original, 1, 1)).toBe(original); + expect(moveItem(original, 5, 0)).toBe(original); + expect(moveItem(original, 0, -1)).toBe(original); + }); + + it('persists the order per workspace and notifies listeners', () => { + const listener = vi.fn(); + window.addEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, listener); + try { + saveWorkspacePageOrder('soc_ui', ['soc-alerts', 'soc-dashboard']); + expect(readWorkspacePageOrder('soc_ui')).toEqual(['soc-alerts', 'soc-dashboard']); + expect(readWorkspacePageOrder('other_ws')).toEqual([]); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toMatchObject({ detail: { workspaceId: 'soc_ui' } }); + + saveWorkspacePageOrder('soc_ui', []); + expect(localStorage.getItem('flocks_layout_workspace_page_order:soc_ui')).toBeNull(); + } finally { + window.removeEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, listener); + } + }); + + it('tolerates corrupted stored values', () => { + localStorage.setItem('flocks_layout_workspace_page_order:soc_ui', '{not json'); + expect(readWorkspacePageOrder('soc_ui')).toEqual([]); + localStorage.setItem('flocks_layout_workspace_page_order:soc_ui', JSON.stringify({ a: 1 })); + expect(readWorkspacePageOrder('soc_ui')).toEqual([]); + }); + + it('orders built-in sidebar entries by href and persists them per group', () => { + const items = [{ href: '/sessions' }, { href: '/workspace' }, { href: '/tasks' }, { href: '/workflows' }]; + expect(applyOrderByKey(items, (item) => item.href, ['/workflows', '/tasks']).map((item) => item.href)) + .toEqual(['/workflows', '/tasks', '/sessions', '/workspace']); + + const listener = vi.fn(); + window.addEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, listener); + try { + saveNavItemOrder('aiWorkbench', ['/workflows', '/sessions']); + expect(readNavItemOrder('aiWorkbench')).toEqual(['/workflows', '/sessions']); + expect(readNavItemOrder('agentHub')).toEqual([]); + expect(localStorage.getItem('flocks_layout_nav_item_order:aiWorkbench')).toBe(JSON.stringify(['/workflows', '/sessions'])); + expect(listener.mock.calls[0][0]).toMatchObject({ detail: { scopeId: 'aiWorkbench' } }); + saveNavItemOrder('aiWorkbench', []); + expect(localStorage.getItem('flocks_layout_nav_item_order:aiWorkbench')).toBeNull(); + } finally { + window.removeEventListener(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, listener); + } + }); +}); diff --git a/webui/src/utils/workspaceNavOrder.ts b/webui/src/utils/workspaceNavOrder.ts new file mode 100644 index 000000000..75ea41bfb --- /dev/null +++ b/webui/src/utils/workspaceNavOrder.ts @@ -0,0 +1,109 @@ +/** + * Per-browser ordering for WebUI contract workspace pages. + * + * The sidebar lets users drag workspace pages (for example the SOC workspace + * second-level menu) into their preferred order. The order is stored per + * workspace in localStorage and applied on top of the server-provided page + * list, so plugin updates that add or remove pages keep working: unknown ids + * are ignored and new pages are appended in their default position. + */ + +export const WORKSPACE_PAGE_ORDER_KEY_PREFIX = 'flocks_layout_workspace_page_order:'; +export const NAV_ITEM_ORDER_KEY_PREFIX = 'flocks_layout_nav_item_order:'; +/** Fired after any sidebar order (workspace pages or built-in entries) is saved. */ +export const WORKSPACE_PAGE_ORDER_CHANGED_EVENT = 'flocks:workspace-page-order-changed'; + +function readStoredOrder(storageKey: string): string[] { + try { + const rawValue = localStorage.getItem(storageKey); + if (!rawValue) return []; + const parsedValue: unknown = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) return []; + return parsedValue.filter((item): item is string => typeof item === 'string'); + } catch { + return []; + } +} + +function saveStoredOrder(storageKey: string, keys: string[], detail: Record): void { + try { + if (keys.length === 0) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, JSON.stringify(keys)); + } + } catch { + // Local storage can be unavailable in restricted browser contexts. + } + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(WORKSPACE_PAGE_ORDER_CHANGED_EVENT, { detail })); + } +} + +export function workspacePageOrderStorageKey(workspaceId: string): string { + return `${WORKSPACE_PAGE_ORDER_KEY_PREFIX}${workspaceId}`; +} + +export function readWorkspacePageOrder(workspaceId: string): string[] { + return readStoredOrder(workspacePageOrderStorageKey(workspaceId)); +} + +export function saveWorkspacePageOrder(workspaceId: string, pageIds: string[]): void { + saveStoredOrder(workspacePageOrderStorageKey(workspaceId), pageIds, { workspaceId }); +} + +/** Order of built-in sidebar entries inside one group (AI workbench, agent studio...). */ +export function navItemOrderStorageKey(scopeId: string): string { + return `${NAV_ITEM_ORDER_KEY_PREFIX}${scopeId}`; +} + +export function readNavItemOrder(scopeId: string): string[] { + return readStoredOrder(navItemOrderStorageKey(scopeId)); +} + +export function saveNavItemOrder(scopeId: string, keys: string[]): void { + saveStoredOrder(navItemOrderStorageKey(scopeId), keys, { scopeId }); +} + +/** + * Reorder `items` following `order` (a list of keys). Items missing from + * `order` keep their relative default position and are appended after the + * ordered ones; keys in `order` that no longer exist are ignored. + */ +export function applyOrderByKey(items: T[], keyOf: (item: T) => string, order: string[]): T[] { + if (order.length === 0 || items.length < 2) return items; + const byKey = new Map(items.map((item) => [keyOf(item), item])); + const ordered: T[] = []; + const seen = new Set(); + for (const key of order) { + const item = byKey.get(key); + if (!item || seen.has(key)) continue; + seen.add(key); + ordered.push(item); + } + for (const item of items) { + if (!seen.has(keyOf(item))) ordered.push(item); + } + return ordered; +} + +/** `applyOrderByKey` for workspace pages, keyed by page id. */ +export function applyWorkspacePageOrder(pages: T[], order: string[]): T[] { + return applyOrderByKey(pages, (page) => page.id, order); +} + +export function moveItem(items: T[], fromIndex: number, toIndex: number): T[] { + if ( + fromIndex === toIndex + || fromIndex < 0 + || toIndex < 0 + || fromIndex >= items.length + || toIndex >= items.length + ) { + return items; + } + const next = [...items]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; +} From ad34665c6ef3ae5c26a686ce2d7a3584e601767b Mon Sep 17 00:00:00 2001 From: jamon Date: Thu, 10 Sep 2026 10:03:37 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(webui):=20=E5=AF=BC=E8=88=AA=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=89=E4=B8=AA=E5=9B=BA=E5=AE=9A=E5=88=86=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶栏只保留 Agent、SOC工作区、系统设置三个分区,左侧只显示当前分区的菜单。每个访问过的 菜单项各自保活,切走再切回来页面还是原样(滚动位置、未提交的输入都在),分区之间也各自 记住上次停留的页面。 - 系统设置改为在布局内渲染,不再是带「返回」的独立整屏页 - 场景工作区的页面平铺成同一级菜单,不做分组 - 场景套件的安装、停用、卸载统一放到场景工作区,插件广场不再列出场景套件 - Hub 插件增加 edition 字段:Pro 专属套件(代码审计、AI 红队)仍然列出但拒绝安装, 直接访问它们的路由会说明需要 Flocks Pro - 场景工作区可以停用而不卸载套件,停用后旧页面链接给出提示 - 安装/更新套件时会带上版本落后的子件,避免套件与它的页面版本脱节 Claude-Session: https://claude.ai/code/session_019toMtTXUWkgQBBRfUCy2PJ --- .flocks/flockshub/index.json | 82 +- .../ai-redteam-workspace/component.json | 14 + .../ai-redteam-workspace/manifest.json | 66 ++ .../code-audit-workspace/component.json | 14 + .../code-audit-workspace/manifest.json | 66 ++ .../ai_redteam_overview/manifest.json | 12 + .../ai_redteam_overview/src/Page.tsx | 14 + .../ai_redteam_overview/src/index.tsx | 3 + .../ai_redteam_surface/manifest.json | 12 + .../ai_redteam_surface/src/Page.tsx | 14 + .../ai_redteam_surface/src/index.tsx | 3 + .../webuis/ai_redteam_ui/manifest.json | 61 ++ .../webuis/ai_redteam_ui/workspace.json | 11 + .../code_audit_findings/manifest.json | 12 + .../code_audit_findings/src/Page.tsx | 14 + .../code_audit_findings/src/index.tsx | 3 + .../code_audit_overview/manifest.json | 12 + .../code_audit_overview/src/Page.tsx | 14 + .../code_audit_overview/src/index.tsx | 3 + .../webuis/code_audit_ui/manifest.json | 61 ++ .../webuis/code_audit_ui/workspace.json | 11 + .../plugins/webuis/soc_ui/manifest.json | 2 +- .../webuis/soc_ui/soc_dashboard/manifest.json | 2 +- .../webuis/soc_ui/soc_dashboard/src/Page.tsx | 6 +- .../plugins/webuis/soc_ui/workspace.json | 2 +- flocks/contracts/webui/store.py | 59 +- flocks/hub/catalog.py | 4 + flocks/hub/installer.py | 40 +- flocks/hub/models.py | 7 + flocks/server/routes/hub.py | 94 +- flocks/server/routes/webui.py | 21 + .../webui/test_store_workspace_state.py | 87 ++ tests/hub/test_hub_catalog.py | 6 +- tests/hub/test_scene_suites.py | 64 ++ webui/src/api/hub.ts | 18 + webui/src/api/webuiContractPages.ts | 6 + .../src/components/layout/KeepAlivePanes.tsx | 96 ++ webui/src/components/layout/Layout.test.tsx | 869 +++++++++++------- webui/src/components/layout/Layout.tsx | 824 ++++++++++------- webui/src/components/layout/OpenTabsBar.tsx | 208 +++++ .../src/components/layout/PartitionTopBar.tsx | 76 ++ webui/src/hooks/useLayoutOpenTabs.ts | 25 + webui/src/hooks/useWorkspacePageOrders.ts | 42 + webui/src/locales/en-US/nav.json | 11 +- .../src/locales/en-US/webuiContractPage.json | 11 +- webui/src/locales/zh-CN/nav.json | 11 +- .../src/locales/zh-CN/webuiContractPage.json | 11 +- webui/src/pages/Hub/index.tsx | 8 +- webui/src/pages/SceneSuites/index.test.tsx | 166 ++++ webui/src/pages/SceneSuites/index.tsx | 283 ++++++ webui/src/pages/Settings/index.test.tsx | 50 +- webui/src/pages/Settings/index.tsx | 262 +----- .../WebUIContractWorkspaceHost/index.test.tsx | 80 +- .../WebUIContractWorkspaceHost/index.tsx | 99 +- webui/src/routes/contentRoutes.tsx | 152 +++ webui/src/routes/index.tsx | 140 +-- webui/src/utils/layoutTabs.test.ts | 71 ++ webui/src/utils/layoutTabs.ts | 104 +++ webui/src/utils/navPartitions.test.ts | 53 ++ webui/src/utils/navPartitions.ts | 62 ++ webui/src/utils/settingsSections.ts | 144 +++ .../utils/webuiContractWorkspaceSections.ts | 21 + webui/src/utils/workspaceNavOrder.test.ts | 90 ++ webui/src/utils/workspaceNavOrder.ts | 109 +++ 64 files changed, 3872 insertions(+), 1126 deletions(-) create mode 100644 .flocks/flockshub/plugins/components/ai-redteam-workspace/component.json create mode 100644 .flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json create mode 100644 .flocks/flockshub/plugins/components/code-audit-workspace/component.json create mode 100644 .flocks/flockshub/plugins/components/code-audit-workspace/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json create mode 100644 .flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json create mode 100644 tests/contracts/webui/test_store_workspace_state.py create mode 100644 tests/hub/test_scene_suites.py create mode 100644 webui/src/components/layout/KeepAlivePanes.tsx create mode 100644 webui/src/components/layout/OpenTabsBar.tsx create mode 100644 webui/src/components/layout/PartitionTopBar.tsx create mode 100644 webui/src/hooks/useLayoutOpenTabs.ts create mode 100644 webui/src/hooks/useWorkspacePageOrders.ts create mode 100644 webui/src/pages/SceneSuites/index.test.tsx create mode 100644 webui/src/pages/SceneSuites/index.tsx create mode 100644 webui/src/routes/contentRoutes.tsx create mode 100644 webui/src/utils/layoutTabs.test.ts create mode 100644 webui/src/utils/layoutTabs.ts create mode 100644 webui/src/utils/navPartitions.test.ts create mode 100644 webui/src/utils/navPartitions.ts create mode 100644 webui/src/utils/settingsSections.ts create mode 100644 webui/src/utils/workspaceNavOrder.test.ts create mode 100644 webui/src/utils/workspaceNavOrder.ts diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 4d3560e97..3baac2e34 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14640,7 +14640,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "category": "workflow-automation", "tags": [ "siem", @@ -14746,6 +14746,86 @@ "trust": "official", "riskLevel": "medium", "manifestPath": "plugins/components/soc-workspace/manifest.json" + }, + { + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/code_audit_ui/manifest.json" + }, + { + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/code-audit-workspace/manifest.json" + }, + { + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/ai_redteam_ui/manifest.json" + }, + { + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/ai-redteam-workspace/manifest.json" } ] } diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json new file mode 100644 index 000000000..72c3a7342 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "ai-redteam-workspace", + "name": "AI Redteam Workspace", + "nameCn": "AI 红队工作区", + "description": "Composite package for the Flocks AI redteam workspace WebUI.", + "descriptionCn": "Flocks AI 红队工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json new file mode 100644 index 000000000..6dbb294e3 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/ai-redteam-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/component.json b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json new file mode 100644 index 000000000..4d5da5021 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "code-audit-workspace", + "name": "Code Audit Workspace", + "nameCn": "代码审计工作区", + "description": "Composite package for the Flocks code audit workspace WebUI.", + "descriptionCn": "Flocks 代码审计工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json new file mode 100644 index 000000000..89886bab4 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/code-audit-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json new file mode 100644 index 000000000..777ed3f52 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-overview", + "title": "演练总览", + "titleEn": "Campaign Overview", + "route": "/contracts/webui/ai-redteam-overview", + "icon": "Crosshair", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx new file mode 100644 index 000000000..4a867e5a0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

AI 红队场景的入口:编排演练、跟踪每一轮的结论。

+
    +
  • 选择目标系统与授权范围,创建一次演练
  • +
  • 演练过程由红队 Agent 驱动,每一步都留下可复核的记录
  • +
  • 演练结束后生成结论,可直接转成整改任务
  • +
+
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json new file mode 100644 index 000000000..0979aec6d --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-surface", + "title": "攻击面", + "titleEn": "Attack Surface", + "route": "/contracts/webui/ai-redteam-surface", + "icon": "Radar", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx new file mode 100644 index 000000000..a93aa94b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

汇总目标系统暴露出的攻击面与已验证的利用路径。

+
    +
  • 按资产、入口、权限层级组织攻击面
  • +
  • 每条利用路径关联到验证它的那一次演练
  • +
  • 已修复的路径保留历史,便于回归验证
  • +
+
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json new file mode 100644 index 000000000..eb4358c3f --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/ai_redteam_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json new file mode 100644 index 000000000..161447dd0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "ai_redteam_ui", + "version": "1.0.0", + "title": "AI 红队", + "titleEn": "AI Redteam", + "icon": "Crosshair", + "order": 30, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "ai-redteam-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json new file mode 100644 index 000000000..1d3a2cd1b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-findings", + "title": "缺陷清单", + "titleEn": "Findings", + "route": "/contracts/webui/code-audit-findings", + "icon": "ListChecks", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx new file mode 100644 index 000000000..0de7204b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

按严重级别列出代码审计发现的问题,支持指派与复核。

+
    +
  • 按严重级别、文件、规则筛选
  • +
  • 每条缺陷保留触发它的代码位置与判定依据
  • +
  • 复核通过后标记为已处理,下次审计不再重复报告
  • +
+
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json new file mode 100644 index 000000000..7d64ec965 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-overview", + "title": "审计总览", + "titleEn": "Audit Overview", + "route": "/contracts/webui/code-audit-overview", + "icon": "FileSearch", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx new file mode 100644 index 000000000..62d6b9e99 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

代码审计场景的入口:接入仓库、发起审计、跟踪修复进度。

+
    +
  • 在 Agent 分区的工作台里用代码审计 Agent 发起一次审计
  • +
  • 审计结果按仓库、分支、提交聚合,进入缺陷清单查看
  • +
  • 缺陷修复后重新审计,对比上一次的结论
  • +
+
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json new file mode 100644 index 000000000..70b899324 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/code_audit_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json new file mode 100644 index 000000000..01ac6d16b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "code_audit_ui", + "version": "1.0.0", + "title": "代码审计", + "titleEn": "Code Audit", + "icon": "FileSearch", + "order": 20, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "code-audit-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json index 25c7d5cdd..cfa3f5464 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json @@ -5,7 +5,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.5", + "version": "1.1.6", "author": "Flocks Team", "license": "MIT", "homepage": "", diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json index afabbbcb0..ddab530e0 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json @@ -8,5 +8,5 @@ "enabled": true, "placement": "home.after", "entry": "src/index.tsx", - "updatedAt": 1781509788949 + "updatedAt": 1788857311724 } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx index c08e3d499..9e0092c6a 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -2981,7 +2981,7 @@ export default function Page() { const CSS = ` .adtd-root { box-sizing: border-box; - min-height: 100vh; + min-height: 100%; min-width: 1100px; margin: 0; padding: 0 16px; @@ -4296,7 +4296,7 @@ const CSS = ` --command-blue: #3677bc; --command-border: rgba(255, 255, 255, .09); position: relative; - height: 100vh; + height: 100%; min-height: 720px; min-width: 1180px; margin: 0; @@ -4516,7 +4516,7 @@ const CSS = ` z-index: 2; display: grid; grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 330px); - height: calc(100vh - 68px); + height: calc(100% - 68px); min-height: 652px; transition: grid-template-columns .24s ease; } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json index 3bf045c38..730c229bd 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json @@ -1,6 +1,6 @@ { "id": "soc_ui", - "version": "1.1.5", + "version": "1.1.6", "title": "SOC 工作区", "titleEn": "SOC Workspace", "icon": "ShieldCheck", diff --git a/flocks/contracts/webui/store.py b/flocks/contracts/webui/store.py index 7222ba9a9..ca33f41e1 100644 --- a/flocks/contracts/webui/store.py +++ b/flocks/contracts/webui/store.py @@ -27,6 +27,9 @@ WORKSPACE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$") MAX_SOURCE_FILE_BYTES = 512_000 ALLOWED_WRITE_PREFIXES = ("src/", "assets/", "api/") +# Workspaces are disabled from the UI without touching the plugin files, so a +# suite update never resurrects a workspace the operator turned off. +WORKSPACE_STATE_FILE = ".workspace-state.json" ALLOWED_WRITE_FILES = frozenset({"manifest.json"}) WORKSPACE_MANIFEST_FILE = "workspace.json" _SOURCE_SUFFIXES = {".tsx", ".ts", ".jsx", ".js", ".css", ".json"} @@ -199,10 +202,44 @@ def _assert_writable_relative(self, relative_path: str) -> Path: return Path(rel) raise ValueError(f"writes are not allowed for path: {relative_path}") + def _workspace_state_path(self) -> Path: + return self._root / WORKSPACE_STATE_FILE + + def read_workspace_enabled_overrides(self) -> dict[str, bool]: + """Operator overrides of `enabled`, keyed by workspace id.""" + try: + raw = self._workspace_state_path().read_text(encoding="utf-8") + except OSError: + return {} + try: + data = json.loads(raw) + except ValueError: + log.warning("webui.workspace_state.invalid", {"path": str(self._workspace_state_path())}) + return {} + enabled = data.get("enabled") if isinstance(data, dict) else None + if not isinstance(enabled, dict): + return {} + return {str(key): bool(value) for key, value in enabled.items()} + + def set_workspace_enabled(self, workspace_id: str, enabled: bool) -> WebUIWorkspaceListItem: + workspace_id = self.validate_workspace_id(workspace_id) + overrides = self.read_workspace_enabled_overrides() + overrides[workspace_id] = bool(enabled) + self.ensure_root() + self._workspace_state_path().write_text( + json.dumps({"enabled": overrides}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + for workspace in self.list_workspaces(): + if workspace.id == workspace_id: + return workspace + raise FileNotFoundError(f"工作区不存在: {workspace_id}") + def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: self.ensure_root() items: list[WebUIPageListItem] = [] seen_keys: set[str] = set() + overrides = self.read_workspace_enabled_overrides() for root in self._read_roots: if not root.is_dir(): continue @@ -215,10 +252,15 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: if page_id in seen_keys or manifest.id in seen_keys: continue seen_keys.update({page_id, manifest.id}) - if enabled_only and not manifest.enabled: + workspace = self._workspace_for_page_dir(root, page_dir) + # A page of a disabled workspace is off too, so old links to it + # stop resolving until the workspace is enabled again. + page_enabled = manifest.enabled and ( + overrides.get(workspace.id, True) if workspace else True + ) + if enabled_only and not page_enabled: continue build = self._read_build_meta_at(page_dir) - workspace = self._workspace_for_page_dir(root, page_dir) items.append( WebUIPageListItem( id=manifest.id, @@ -227,7 +269,7 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: route=manifest.route, icon=manifest.icon, order=manifest.order, - enabled=manifest.enabled, + enabled=page_enabled, placement=manifest.placement, buildHash=build.hash, buildStatus=build.status, @@ -244,6 +286,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL self.ensure_root() workspaces: list[WebUIWorkspaceListItem] = [] seen_workspace_ids: set[str] = set() + overrides = self.read_workspace_enabled_overrides() for root in self._read_roots: if not root.is_dir(): continue @@ -251,7 +294,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL if manifest.id in seen_workspace_ids: continue seen_workspace_ids.add(manifest.id) - if enabled_only and not manifest.enabled: + workspace_enabled = overrides.get(manifest.id, manifest.enabled) + if enabled_only and not workspace_enabled: continue pages: list[WebUIPageListItem] = [] @@ -263,7 +307,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL if page_manifest is None: continue seen_page_ids.add(page_manifest.id) - if enabled_only and not page_manifest.enabled: + page_enabled = page_manifest.enabled and workspace_enabled + if enabled_only and not page_enabled: continue build = self._read_build_meta_at(page_dir) pages.append( @@ -274,7 +319,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL route=page_manifest.route, icon=page_manifest.icon, order=page_manifest.order, - enabled=page_manifest.enabled, + enabled=page_enabled, placement=page_manifest.placement, buildHash=build.hash, buildStatus=build.status, @@ -294,7 +339,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL route=webui_contract_workspace_route(manifest.id), icon=manifest.icon, order=manifest.order, - enabled=manifest.enabled, + enabled=workspace_enabled, placement=manifest.placement, defaultPageId=manifest.defaultPageId, sections=manifest.sections, diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index a999a12b3..24b423960 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -807,6 +807,7 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state=state, installedVersion=installed_version, source=manifest.source.kind, @@ -866,6 +867,7 @@ def _entry_from_index( useCases=item.useCases, trust=item.trust, riskLevel=item.riskLevel, + edition=item.edition, state=state, installedVersion=installed_version, source="bundled", @@ -891,6 +893,7 @@ def _entry_from_system_manifest(manifest: HubPluginManifest, root: Path) -> HubC capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state="installed", installedVersion=manifest.version, source="system", @@ -959,6 +962,7 @@ def _entry_from_bundled_tool( capabilities=manifest.capabilities, trust=manifest.trust, riskLevel=manifest.risk.level, + edition=getattr(manifest, "edition", "oss"), state=state, installedVersion=installed_version, source="bundled", diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index eb894126c..a08080ae3 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -11,7 +11,7 @@ from typing import Awaitable, Callable from flocks.hub import local -from flocks.hub.catalog import clear_catalog_caches, load_manifest +from flocks.hub.catalog import _version_tuple, clear_catalog_caches, load_manifest from flocks.hub.files import plugin_root from flocks.hub.models import ( HubComponentRef, @@ -464,6 +464,22 @@ def _can_adopt_existing_ref( return record.source == _bundled_source_for_ref(ref) +def _component_ref_is_outdated( + ref: HubComponentRef, + install_path: Path, + record: Optional[InstalledPluginRecord], +) -> bool: + """True when the installed child is older than the version this suite ships.""" + try: + available = load_manifest(ref.type, ref.id).version + except Exception: + return False + installed = record.version if record is not None else local.installed_payload_version(ref.type, install_path) + if not installed: + return False + return _version_tuple(installed) < _version_tuple(available) + + async def _install_component_refs( manifest: HubPluginManifest, *, @@ -492,6 +508,28 @@ async def _install_component_refs( existing_path = local.infer_local_install(ref.type, ref.id) if existing_path is not None: existing_record = local.get_record(ref.type, ref.id) + # Installing or updating the suite has to bring an outdated + # child along, otherwise the suite reads as current while its + # pages stay on the old version. + if _component_ref_is_outdated(ref, existing_path, existing_record): + item.status = "installing" + await _emit_component_progress(progress, manifest, "item", item=item) + try: + await install_plugin(ref.type, ref.id, scope=scope, installed_by=component_key) + except Exception as exc: + if ref.optional: + item.status = "skipped" + item.message = f"Optional dependency failed to update: {exc}" + await _emit_component_progress(progress, manifest, "item", item=item) + continue + item.status = "failed" + item.message = str(exc) or "Update failed" + await _emit_component_progress(progress, manifest, "item", item=item) + raise + item.status = "installed" + item.message = "Updated by component" + await _emit_component_progress(progress, manifest, "item", item=item) + continue if existing_record is not None and _can_adopt_existing_ref(ref, existing_record, component_key, existing_path): adopted_records.append(existing_record) local.save_installed_record(existing_record.model_copy(update={"installedBy": component_key})) diff --git a/flocks/hub/models.py b/flocks/hub/models.py index a9640d21a..4b24c2883 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -8,6 +8,10 @@ PluginType = Literal["skill", "agent", "tool", "device", "workflow", "webui", "component"] +# Which edition may install a plugin. Pro-only suites stay visible in the +# catalog so the operator can see what an upgrade unlocks. +PluginEdition = Literal["oss", "pro"] + PluginState = Literal[ "available", "installed", @@ -75,6 +79,7 @@ class HubPluginManifest(BaseModel): domains: list[str] = Field(default_factory=list) capabilities: list[str] = Field(default_factory=list) trust: Literal["official", "verified", "community", "experimental", "deprecated"] = "community" + edition: PluginEdition = "oss" source: HubSource = Field(default_factory=HubSource) compatibility: HubCompatibility = Field(default_factory=HubCompatibility) dependencies: HubDependencies = Field(default_factory=HubDependencies) @@ -98,6 +103,7 @@ class HubIndexEntry(BaseModel): useCases: list[str] = Field(default_factory=list) trust: str = "community" riskLevel: str = "low" + edition: PluginEdition = "oss" manifestPath: str @@ -162,6 +168,7 @@ class HubCatalogEntry(BaseModel): capabilities: list[str] = Field(default_factory=list) trust: str = "community" riskLevel: str = "low" + edition: PluginEdition = "oss" state: PluginState = "available" installedVersion: Optional[str] = None source: str = "bundled" diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index 5c6882519..8d5568fe1 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -19,6 +19,7 @@ load_taxonomy, ) from flocks.hub.files import file_tree, read_file_content +from flocks.license import license_status from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.hub.models import ( HubCatalogEntry, @@ -29,7 +30,7 @@ InstalledPluginRecord, PluginType, ) -from flocks.server.auth import require_admin +from flocks.server.auth import require_admin, require_user from flocks.utils.log import Log @@ -220,6 +221,95 @@ async def hub_plugin_file_content(plugin_type: PluginType, plugin_id: str, path: raise HTTPException(status_code=400, detail=str(exc)) from exc +class SceneSuiteEntry(BaseModel): + """A scene suite as the scene workspace shows it: catalog state + workspace state.""" + + id: str + name: str + nameCn: Optional[str] = None + description: str = "" + descriptionCn: Optional[str] = None + version: str = "0.0.0" + installedVersion: Optional[str] = None + edition: str = "oss" + state: str = "available" + workspaceId: Optional[str] = None + workspaceTitle: Optional[str] = None + workspaceRoute: Optional[str] = None + workspaceEnabled: Optional[bool] = None + + +def _suite_workspace_id(plugin_id: str) -> Optional[str]: + try: + manifest = load_manifest("component", plugin_id) + except Exception: + return None + for component in getattr(manifest, "components", []) or []: + if getattr(component, "type", None) == "webui": + return getattr(component, "id", None) + return None + + +@router.get("/hub/scene-suites", response_model=list[SceneSuiteEntry]) +async def hub_scene_suites(_user: object = Depends(require_user)): + """Scene suites for the scene-workspace suite manager (Hub no longer lists them).""" + from flocks.contracts.webui.store import WebUIPagesStore, webui_contract_workspace_route + + catalog = list_catalog() + entries = [entry for entry in catalog if entry.type == "component"] + webui_entries = {entry.id: entry for entry in catalog if entry.type == "webui"} + workspaces = {item.id: item for item in WebUIPagesStore().list_workspaces()} + suites: list[SceneSuiteEntry] = [] + for entry in entries: + workspace_id = _suite_workspace_id(entry.id) + workspace = workspaces.get(workspace_id) if workspace_id else None + # A suite is only current when its pages are current too: the component + # shell and its WebUI package carry separate versions. + state = entry.state + child = webui_entries.get(workspace_id) if workspace_id else None + if state == "installed" and child is not None and child.state == "updateAvailable": + state = "updateAvailable" + suites.append( + SceneSuiteEntry( + id=entry.id, + name=entry.name, + nameCn=entry.nameCn, + description=entry.description, + descriptionCn=entry.descriptionCn, + version=entry.version, + installedVersion=entry.installedVersion, + edition=entry.edition, + state=state, + workspaceId=workspace_id, + workspaceTitle=workspace.title if workspace else None, + workspaceRoute=webui_contract_workspace_route(workspace.id) if workspace else None, + workspaceEnabled=workspace.enabled if workspace else None, + ) + ) + suites.sort(key=lambda item: (item.edition != "oss", item.id)) + return suites + + +async def _assert_edition_allowed(plugin_type: PluginType, plugin_id: str) -> None: + """Pro-only suites stay listed in the catalog but only install under Pro.""" + try: + manifest = load_manifest(plugin_type, plugin_id) + except Exception: + return + if getattr(manifest, "edition", "oss") != "pro": + return + try: + status_payload = await license_status() + except Exception: + status_payload = {} + edition = str(status_payload.get("status") or "").lower() + if edition == "oss" or not status_payload.get("active"): + raise HTTPException( + status_code=403, + detail="该套件需要 Flocks Pro 授权后才能安装,请先在系统设置中升级。", + ) + + @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install", response_model=InstalledPluginRecord) async def hub_install_plugin( plugin_type: PluginType, @@ -228,6 +318,7 @@ async def hub_install_plugin( _admin: object = Depends(require_admin), ): _guard_legacy_removed_plugin(plugin_type, plugin_id) + await _assert_edition_allowed(plugin_type, plugin_id) try: return await install_plugin(plugin_type, plugin_id, scope=req.scope) except Exception as exc: @@ -243,6 +334,7 @@ async def hub_install_plugin_stream( _admin: object = Depends(require_admin), ): _guard_legacy_removed_plugin(plugin_type, plugin_id) + await _assert_edition_allowed(plugin_type, plugin_id) if plugin_type != "component": raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.") try: diff --git a/flocks/server/routes/webui.py b/flocks/server/routes/webui.py index 0271b4a85..c42ceda6f 100644 --- a/flocks/server/routes/webui.py +++ b/flocks/server/routes/webui.py @@ -158,6 +158,27 @@ async def list_webui_workspaces(enabled_only: bool = Query(False, alias="enabled return _store.list_workspaces(enabled_only=enabled_only) +class WebUIWorkspaceEnabledRequest(BaseModel): + enabled: bool = Field(..., description="Whether the workspace appears in navigation") + + +@router.patch("/contracts/webui/workspaces/{workspace_id}", response_model=WebUIWorkspaceListItem) +async def set_webui_workspace_enabled( + workspace_id: str, + req: WebUIWorkspaceEnabledRequest, + _admin: object = Depends(require_admin), +): + """Turn a scene workspace off or back on without uninstalling its suite.""" + try: + workspace = _store.set_workspace_enabled(workspace_id, req.enabled) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + await publish_event("contracts.webui.pages.nav_changed", {"workspaceId": workspace_id, "enabled": req.enabled}) + return workspace + + @router.post("/contracts/webui/pages", response_model=WebUIPageDetail, status_code=status.HTTP_201_CREATED) async def create_webui_page(req: WebUIPageCreateRequest, _admin: object = Depends(require_admin)): try: diff --git a/tests/contracts/webui/test_store_workspace_state.py b/tests/contracts/webui/test_store_workspace_state.py new file mode 100644 index 000000000..88ddbfe74 --- /dev/null +++ b/tests/contracts/webui/test_store_workspace_state.py @@ -0,0 +1,87 @@ +"""Turning a scene workspace off hides it and its pages without touching plugin files.""" + +import json + +import pytest + +from flocks.contracts.webui.store import WebUIPagesStore + + +def _write(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture +def workspace_store(tmp_path, monkeypatch): + root = tmp_path / "webui_pages" + monkeypatch.setenv("FLOCKS_CONTRACTS_WEBUI_ROOT", str(root)) + _write( + root / "scene_ui" / "workspace.json", + { + "id": "scene_ui", + "version": "1.0.0", + "title": "场景工作区", + "icon": "ShieldCheck", + "order": 10, + "enabled": True, + "placement": "sceneWorkspace", + "defaultPageId": "scene-overview", + }, + ) + _write( + root / "scene_ui" / "scene_overview" / "manifest.json", + { + "id": "scene-overview", + "title": "总览", + "route": "/contracts/webui/scene-overview", + "icon": "Shield", + "order": 10, + "enabled": True, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 0, + }, + ) + return WebUIPagesStore() + + +def test_workspace_starts_enabled(workspace_store: WebUIPagesStore): + workspaces = workspace_store.list_workspaces() + assert [item.id for item in workspaces] == ["scene_ui"] + assert workspaces[0].enabled is True + assert [page.id for page in workspaces[0].pages] == ["scene-overview"] + + +def test_disable_hides_workspace_and_its_pages(workspace_store: WebUIPagesStore): + updated = workspace_store.set_workspace_enabled("scene_ui", False) + + assert updated.enabled is False + assert [page.enabled for page in updated.pages] == [False] + assert workspace_store.list_workspaces(enabled_only=True) == [] + assert workspace_store.list_pages(enabled_only=True) == [] + # The page is still listed when the caller wants everything, just disabled. + assert [(page.id, page.enabled) for page in workspace_store.list_pages()] == [("scene-overview", False)] + + +def test_enable_restores_workspace(workspace_store: WebUIPagesStore): + workspace_store.set_workspace_enabled("scene_ui", False) + restored = workspace_store.set_workspace_enabled("scene_ui", True) + + assert restored.enabled is True + assert [item.id for item in workspace_store.list_workspaces(enabled_only=True)] == ["scene_ui"] + assert [page.id for page in workspace_store.list_pages(enabled_only=True)] == ["scene-overview"] + + +def test_state_is_kept_out_of_the_plugin_files(workspace_store: WebUIPagesStore, tmp_path): + workspace_store.set_workspace_enabled("scene_ui", False) + + manifest = json.loads((tmp_path / "webui_pages" / "scene_ui" / "workspace.json").read_text(encoding="utf-8")) + assert manifest["enabled"] is True, "the suite's own manifest must stay untouched" + state = json.loads((tmp_path / "webui_pages" / ".workspace-state.json").read_text(encoding="utf-8")) + assert state == {"enabled": {"scene_ui": False}} + + +def test_unknown_workspace_id_is_rejected(workspace_store: WebUIPagesStore): + with pytest.raises(FileNotFoundError): + workspace_store.set_workspace_enabled("missing_ui", False) diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 60315efa6..831e7c15e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -296,17 +296,17 @@ def test_catalog_uses_webui_workspace_version_for_inferred_installs( entry = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] - assert entry.version == "1.1.5" + assert entry.version == "1.1.6" assert entry.state == "updateAvailable" assert entry.installedVersion == "1.0.0" - workspace["version"] = "1.1.5" + workspace["version"] = "1.1.6" workspace_path.write_text(json.dumps(workspace), encoding="utf-8") refreshed = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"] assert refreshed.state == "installed" - assert refreshed.installedVersion == "1.1.5" + assert refreshed.installedVersion == "1.1.6" def test_pentest_agents_are_listed_in_agent_catalog(): diff --git a/tests/hub/test_scene_suites.py b/tests/hub/test_scene_suites.py new file mode 100644 index 000000000..2ddfd9dc7 --- /dev/null +++ b/tests/hub/test_scene_suites.py @@ -0,0 +1,64 @@ +"""Scene suites are the hub's component packages, gated by edition.""" + +import pytest +from fastapi import HTTPException + +from flocks.hub.catalog import list_catalog, load_manifest +from flocks.server.routes.hub import _assert_edition_allowed, _suite_workspace_id + + +def _component_entries(): + return {entry.id: entry for entry in list_catalog() if entry.type == "component"} + + +def test_bundled_scene_suites_declare_their_edition(): + entries = _component_entries() + + assert set(entries) == {"soc-workspace", "code-audit-workspace", "ai-redteam-workspace"} + assert entries["soc-workspace"].edition == "oss" + assert entries["code-audit-workspace"].edition == "pro" + assert entries["ai-redteam-workspace"].edition == "pro" + + +@pytest.mark.parametrize( + ("suite_id", "workspace_id"), + [ + ("soc-workspace", "soc_ui"), + ("code-audit-workspace", "code_audit_ui"), + ("ai-redteam-workspace", "ai_redteam_ui"), + ], +) +def test_suite_resolves_to_its_workspace(suite_id: str, workspace_id: str): + assert _suite_workspace_id(suite_id) == workspace_id + + +def test_pro_workspaces_are_scene_workspaces(): + for workspace_id in ("code_audit_ui", "ai_redteam_ui"): + manifest = load_manifest("webui", workspace_id) + assert manifest.edition == "pro" + + +@pytest.mark.asyncio +async def test_oss_may_install_an_oss_suite(): + # No exception: the OSS license checker reports edition "oss" and this suite + # does not require Pro. + await _assert_edition_allowed("component", "soc-workspace") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("suite_id", ["code-audit-workspace", "ai-redteam-workspace"]) +async def test_oss_may_not_install_a_pro_suite(suite_id: str): + with pytest.raises(HTTPException) as excinfo: + await _assert_edition_allowed("component", suite_id) + + assert excinfo.value.status_code == 403 + assert "Flocks Pro" in excinfo.value.detail + + +@pytest.mark.asyncio +async def test_pro_license_unlocks_a_pro_suite(monkeypatch): + async def pro_status(): + return {"activated": True, "active": True, "status": "pro"} + + monkeypatch.setattr("flocks.server.routes.hub.license_status", pro_status) + await _assert_edition_allowed("component", "code-audit-workspace") diff --git a/webui/src/api/hub.ts b/webui/src/api/hub.ts index 92597601b..ced2ed1be 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -137,7 +137,25 @@ export interface HubCatalogPageResponse { facets: HubCatalogFacets; } +export interface HubSceneSuite { + id: string; + name: string; + nameCn?: string | null; + description: string; + descriptionCn?: string | null; + version: string; + installedVersion?: string | null; + edition: 'oss' | 'pro'; + state: HubPluginState; + workspaceId?: string | null; + workspaceTitle?: string | null; + workspaceRoute?: string | null; + workspaceEnabled?: boolean | null; +} + export const hubAPI = { + sceneSuites: () => client.get('/api/hub/scene-suites'), + catalog: (params?: HubCatalogParams) => client.get('/api/hub/catalog', { params }), diff --git a/webui/src/api/webuiContractPages.ts b/webui/src/api/webuiContractPages.ts index abdee30af..182f83f7e 100644 --- a/webui/src/api/webuiContractPages.ts +++ b/webui/src/api/webuiContractPages.ts @@ -92,6 +92,12 @@ export const webuiContractPagesAPI = { params: enabledOnly ? { enabledOnly: true } : undefined, }), + setWorkspaceEnabled: (workspaceId: string, enabled: boolean) => + client.patch( + `/api/contracts/webui/workspaces/${workspaceId}`, + { enabled }, + ), + create: (payload: WebUIContractPageCreateRequest) => client.post('/api/contracts/webui/pages', payload), diff --git a/webui/src/components/layout/KeepAlivePanes.tsx b/webui/src/components/layout/KeepAlivePanes.tsx new file mode 100644 index 000000000..2a96c0dda --- /dev/null +++ b/webui/src/components/layout/KeepAlivePanes.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react'; +import { + matchPath, + NavigationType, + UNSAFE_LocationContext as LocationContext, + useRoutes, + type Location, + type RouteObject, +} from 'react-router-dom'; +import { FULL_SCREEN_PATH_PATTERNS } from '@/routes/contentRoutes'; + +export interface KeepAlivePane { + /** Tab identity (sidebar entry href). */ + href: string; +} + +interface KeepAlivePanesProps { + panes: KeepAlivePane[]; + activeHref: string | null; + /** The router's live location; rendered by the active pane. */ + location: Location; + routes: RouteObject[]; +} + +export function isFullScreenPath(pathname: string): boolean { + return FULL_SCREEN_PATH_PATTERNS.some((pattern) => matchPath(pattern, pathname) !== null); +} + +interface PaneProps { + active: boolean; + location: Location; + routes: RouteObject[]; +} + +function Pane({ active, location, routes }: PaneProps) { + // Match this pane's own location, and pin `useLocation()` for everything + // inside it, so a hidden tab never reacts to the active tab's route. + const element = useRoutes(routes, location); + const fullScreen = isFullScreenPath(location.pathname); + return ( + +
+ {fullScreen ? element : ( +
+
{element}
+
+ )} +
+
+ ); +} + +/** + * Browser-style tabs keep their page alive: one pane per open tab stays + * mounted (state, scroll position, in-progress edits included) and only the + * active one is visible. Inactive panes are hidden with `visibility` rather + * than `display: none` so their layout and scroll offsets survive. + */ +export default function KeepAlivePanes({ panes, activeHref, location, routes }: KeepAlivePanesProps) { + // Remember the last live location of each pane so a hidden pane keeps the + // exact location object it last rendered with (stable identity, no re-runs). + // A pane is only mounted once it has been active (tabs restored from a + // previous session stay cold until they are opened), like a browser that + // discards background tabs. + const lastLocationsRef = useRef(new Map()); + if (activeHref) { + lastLocationsRef.current.set(activeHref, location); + } + for (const href of Array.from(lastLocationsRef.current.keys())) { + if (!panes.some((pane) => pane.href === href)) lastLocationsRef.current.delete(href); + } + + // Charts and editors measure their container; poke them when a pane comes back. + useEffect(() => { + if (!activeHref) return; + const timer = window.setTimeout(() => window.dispatchEvent(new Event('resize')), 0); + return () => window.clearTimeout(timer); + }, [activeHref]); + + return ( +
+ {panes.map((pane) => { + const active = pane.href === activeHref; + const lastLocation = lastLocationsRef.current.get(pane.href); + if (!active && !lastLocation) return null; + const paneLocation = active ? location : (lastLocation as Location); + return ; + })} +
+ ); +} diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index 9e7347f06..08d2eac52 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { Link, MemoryRouter, Route, Routes, useLocation, type RouteObject } from 'react-router-dom'; import Layout from './Layout'; import Home from '@/pages/Home'; import { UPDATE_DISMISSED_KEY } from '@/utils/updateDismissal'; @@ -233,36 +233,170 @@ function makeOnboardingStatus(overrides: Record = {}) { }; } +// Pages rendered inside the layout under test: Home plus a probe for every +// other path. The layout keeps one pane per open tab mounted, so the probe +// also carries local state to prove a hidden tab survives untouched. +const defaultCustomPage = { + id: 'dash-1', + title: '自定义仪表盘', + route: '/contracts/webui/dash-1', + icon: 'LayoutDashboard', + order: 10, + enabled: true, + placement: 'home.after', + buildHash: 'abc', + buildStatus: 'ready' as const, +}; + +const testContentRoutes: RouteObject[] = [ + { index: true, element: }, + { path: 'sessions', element: }, + { path: 'workflows/*', element: }, + { path: 'contracts/webui/workspaces/:workspaceId/:pageId?', element: }, + { path: '*', element: }, +]; + function renderHomeWithLayout() { - return render( - - - }> - } /> - - - , - ); + return renderLayoutAt('/'); +} + +function renderHomeWithLayoutAndSessionsRoute() { + return renderLayoutAt('/'); } -function LocationProbe() { +/** The location probe of the visible (active) pane; hidden panes keep their own. */ +function activeProbe(): HTMLElement { + const probe = screen.getAllByTestId('location-probe') + .find((element) => element.closest('[data-keep-alive-pane="active"]')); + if (!probe) throw new Error('no active location probe'); + return probe; +} + +function RouteProbe() { const location = useLocation(); - return
{`${location.pathname}${location.search}`}
; + const [count, setCount] = React.useState(0); + return ( +
+
{`${location.pathname}${location.search}`}
+ + {count} + go-soc-alerts + go-sessions + go-workflow-detail +
+ ); } -function renderHomeWithLayoutAndSessionsRoute() { +function renderLayoutAt(path: string) { return render( - + - }> - } /> - } /> - + } /> , ); } +function makeSocPage(id: string, title: string, icon: string, order: number) { + return { + id, + title, + route: `/contracts/webui/${id}`, + icon, + order, + enabled: true, + placement: 'home.after', + buildHash: 'ready', + buildStatus: 'ready' as const, + workspaceId: 'soc_ui', + workspaceTitle: 'SOC 工作区', + workspaceRoute: '/contracts/webui/workspaces/soc_ui', + }; +} + +function mockSocWorkspaceNav() { + const socPages = [ + makeSocPage('soc-dashboard', '态势', 'Activity', 10), + makeSocPage('soc-overview', 'SOC 总览', 'ShieldCheck', 15), + makeSocPage('soc-alerts', '告警调查', 'AlertTriangle', 20), + ]; + useWebUIContractPages.mockReturnValue({ + pages: socPages, + workspaces: [ + { + id: 'soc_ui', + title: 'SOC 工作区', + titleEn: 'SOC Workspace', + route: '/contracts/webui/workspaces/soc_ui', + icon: 'ShieldCheck', + order: 10, + enabled: true, + placement: 'sceneWorkspace', + defaultPageId: 'soc-overview', + sections: [ + { + id: 'posture', + label: '态势', + pageIds: ['soc-dashboard'], + defaultPageId: 'soc-dashboard', + contentPadding: 'none', + themeOverride: 'dark', + }, + { + id: 'operations', + label: '告警运营', + pageIds: ['soc-overview', 'soc-alerts'], + defaultPageId: 'soc-overview', + contentPadding: 'none', + }, + ], + pages: socPages, + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + return socPages; +} + +function partitionNames(): string[] { + return within(screen.getByRole('tablist', { name: 'partitions' })) + .getAllByRole('tab') + .map((tab) => tab.textContent ?? ''); +} + +function activePartitionName(): string { + return within(screen.getByRole('tablist', { name: 'partitions' })) + .getAllByRole('tab') + .find((tab) => tab.getAttribute('aria-selected') === 'true')?.textContent ?? ''; +} + +/** The scene partition renders one nameless section; these read its entries. */ +function sceneMenuSection(): HTMLElement { + const nav = document.querySelector('aside nav') as HTMLElement; + const section = nav.querySelector('div.mb-6'); + if (!section) throw new Error('scene menu not rendered'); + return section as HTMLElement; +} + +function sceneMenuLinks(): string[] { + return Array.from(sceneMenuSection().querySelectorAll('a')).map((link) => link.textContent ?? ''); +} + +function sceneMenuHrefs(): (string | null)[] { + return Array.from(sceneMenuSection().querySelectorAll('a')).map((link) => link.getAttribute('href')); +} + +function sectionHeadings(container: HTMLElement): string[] { + const nav = container.querySelector('aside nav') as HTMLElement; + return Array.from(nav.querySelectorAll('h3')).map((element) => element.textContent ?? ''); +} + +function navPageOrder(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-nav-page-id]')).map((element) => element.getAttribute('data-nav-page-id') ?? ''); +} + async function flushEffects() { await act(async () => { if (vi.isFakeTimers()) { @@ -752,13 +886,11 @@ describe('Layout onboarding entry', () => { expect(screen.getByRole('link', { name: 'Flocks Pro' })).toHaveAttribute('href', '/settings/flockspro'); const updateEntry = screen.getByRole('button', { name: 'checkUpdate' }); - const usageEntry = screen.getByRole('link', { name: 'flocksLlmUsageQuota' }); const settingsEntry = screen.getByRole('link', { name: 'settings' }); - expect(usageEntry).toHaveAttribute('href', 'https://portal.agentflocks.com'); - expect(usageEntry).toHaveAttribute('target', '_blank'); expect(settingsEntry).toHaveAttribute('href', '/settings/preferences'); - expect(updateEntry.compareDocumentPosition(usageEntry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(usageEntry.compareDocumentPosition(settingsEntry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(updateEntry.compareDocumentPosition(settingsEntry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // D02: the usage portal moved into the system settings menu. + expect(screen.queryByRole('link', { name: 'flocksLlmUsageQuota' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'logout' })); expect(logout).toHaveBeenCalledTimes(1); @@ -1060,76 +1192,144 @@ describe('Layout WebUI contract pages navigation', () => { flocksproUsersApi.hasCapability.mockResolvedValue(false); flocksproUsersApi.getLicenseStatus.mockResolvedValue({ pro_enabled: false }); consoleUpgradeApi.getProPackageStatus.mockResolvedValue({ pro_enabled: false }); + onboardingAPI.getStatus.mockResolvedValue({ data: makeOnboardingStatus() }); + useWebUIContractPages.mockReturnValue({ + pages: [defaultCustomPage], + workspaces: [], + loading: false, + error: null, + refetch: vi.fn(), + }); }); - it('renders custom WebUI contract page links under the home section', async () => { - renderHomeWithLayout(); - expect(await screen.findByRole('link', { name: '自定义仪表盘' })).toHaveAttribute( - 'href', - '/contracts/webui/dash-1', - ); + it('shows the three fixed partitions and swaps the sidebar menu when switching', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + const { container } = renderHomeWithLayout(); + + await screen.findByRole('button', { name: 'aiWorkbench' }); + expect(partitionNames()).toEqual(['partitionAgent', 'partitionScene', 'partitionSettings']); + expect(activePartitionName()).toBe('partitionAgent'); + expect(sectionHeadings(container)).toEqual(['aiWorkbench', 'agentHub']); + expect(screen.getByRole('link', { name: 'flocksHome' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'SOC 工作区' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'settingsPreferences' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + // One flat menu: the SOC pages sit at the same level, no group headings. + await waitFor(() => expect(sceneMenuLinks()).toEqual(['态势', 'SOC 总览', '告警调查'])); + expect(sectionHeadings(container)).toEqual([]); + expect(activePartitionName()).toBe('partitionScene'); + expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: '告警调查' })).toBeInTheDocument(); + // The suite manager is a top-bar entry, not a sidebar menu item. + expect(within(screen.getByRole('tablist', { name: 'partitions' }).parentElement as HTMLElement) + .getByRole('link', { name: 'sceneSuiteManager' })).toHaveAttribute('href', '/scenes/suites'); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-dashboard')); + + await user.click(screen.getByRole('tab', { name: 'partitionSettings' })); + await waitFor(() => expect(sectionHeadings(container)).toEqual([ + 'settingsGroupPreferences', + 'settingsGroupData', + 'settingsGroupSystem', + ])); + // D02: the LLM usage portal moved into the system settings menu. + expect(screen.getByRole('link', { name: 'flocksLlmUsageQuota' })) + .toHaveAttribute('href', 'https://portal.agentflocks.com'); + expect(screen.getByRole('link', { name: 'settingsPreferences' })).toHaveAttribute('href', '/settings/preferences'); + expect(screen.getByRole('link', { name: 'accountManagement' })).toHaveAttribute('href', '/settings/account'); + expect(screen.queryByRole('link', { name: '告警调查' })).not.toBeInTheDocument(); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/settings/preferences')); }); - it('keeps sidebar workspace groups expanded by default and allows collapsing each group', async () => { + it('returns each partition to the page it was left on', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); - renderHomeWithLayout(); + renderLayoutAt('/sessions'); + await screen.findByRole('button', { name: 'aiWorkbench' }); - const aiWorkbenchToggle = await screen.findByRole('button', { name: 'aiWorkbench' }); - const sceneWorkspacesToggle = screen.getByRole('button', { name: 'sceneWorkspaces' }); - const agentHubToggle = screen.getByRole('button', { name: 'agentHub' }); + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-dashboard')); - expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); - expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'true'); - expect(agentHubToggle).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + await user.click(screen.getByRole('link', { name: '告警调查' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts')); - await user.click(aiWorkbenchToggle); - expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['aiWorkbench'])); - expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - await user.click(aiWorkbenchToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'partitionAgent' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/sessions')); + expect(activePartitionName()).toBe('partitionAgent'); - await user.click(sceneWorkspacesToggle); - expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['sceneWorkspaces'])); - expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); - await user.click(sceneWorkspacesToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts')); + expect(JSON.parse(localStorage.getItem('flocks_layout_partition_paths') ?? '{}')).toMatchObject({ + agent: '/sessions', + scene: '/contracts/webui/workspaces/soc_ui/soc-alerts', + }); + }); - await user.click(agentHubToggle); - expect(agentHubToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['agentHub'])); - expect(screen.queryByRole('link', { name: 'agents' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - await user.click(agentHubToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + it('follows the route into the partition that owns it', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + await screen.findByRole('button', { name: 'aiWorkbench' }); + expect(activePartitionName()).toBe('partitionAgent'); + + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => expect(activePartitionName()).toBe('partitionScene')); + expect(screen.getByRole('link', { name: '告警调查' })).toHaveClass('bg-white'); + + await user.click(screen.getByRole('link', { name: 'go-sessions' })); + await waitFor(() => expect(activePartitionName()).toBe('partitionAgent')); + expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'true'); }); - it('restores collapsed sidebar workspace groups after refresh', async () => { + it('keeps the suite manager reachable when no scene workspace is installed', async () => { + const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); - localStorage.setItem('flocks_layout_collapsed_nav_sections', JSON.stringify(['sceneWorkspaces'])); + useWebUIContractPages.mockReturnValue({ + pages: [], + workspaces: [], + loading: false, + error: null, + refetch: vi.fn(), + }); renderHomeWithLayout(); + await screen.findByRole('button', { name: 'aiWorkbench' }); + + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + expect(await screen.findByRole('link', { name: 'sceneSuiteManager' })) + .toHaveAttribute('href', '/scenes/suites'); + expect(screen.queryByRole('button', { name: 'SOC 工作区' })).not.toBeInTheDocument(); + // Agent and settings stay usable while no scene workspace exists. + await user.click(screen.getByRole('tab', { name: 'partitionAgent' })); + expect(await screen.findByRole('link', { name: 'sessions' })).toBeInTheDocument(); + }); - expect(await screen.findByRole('button', { name: 'sceneWorkspaces' })).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'agentHub' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + it('renders custom WebUI contract page links in the scene partition', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + renderHomeWithLayout(); + + await screen.findByRole('button', { name: 'aiWorkbench' }); + expect(screen.queryByRole('link', { name: '自定义仪表盘' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + expect(await screen.findByRole('link', { name: '自定义仪表盘' })).toHaveAttribute( + 'href', + '/contracts/webui/dash-1', + ); }); it('does not render WebUI contract page links until their build is ready', async () => { + const user = userEvent.setup(); useWebUIContractPages.mockReturnValue({ pages: [ { @@ -1161,257 +1361,180 @@ describe('Layout WebUI contract pages navigation', () => { }); renderHomeWithLayout(); + await screen.findByRole('button', { name: 'aiWorkbench' }); + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); expect(await screen.findByRole('link', { name: '可用页面' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: '失败页面' })).not.toBeInTheDocument(); }); - it('renders WebUI workspaces and device integration in the scene workspace group', async () => { + it('keeps the accordion of each partition independent', async () => { const user = userEvent.setup(); - const workspacePages = [ - { - id: 'risk-dashboard', - title: '态势看板', - route: '/contracts/webui/risk-dashboard', - icon: 'Activity', - order: 30, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - { - id: 'ops-overview', - title: '运营总览', - route: '/contracts/webui/ops-overview', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - { - id: 'investigation-list', - title: '调查列表', - route: '/contracts/webui/investigation-list', - icon: 'AlertTriangle', - order: 20, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'scene_workspace', - workspaceTitle: '场景工作区', - workspaceRoute: '/contracts/webui/workspaces/scene_workspace', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: workspacePages, - workspaces: [ - { - id: 'scene_workspace', - title: '场景工作区', - route: '/contracts/webui/workspaces/scene_workspace', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'ops-overview', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['risk-dashboard'], - defaultPageId: 'risk-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - { - id: 'operations', - label: '调查列表', - pageIds: ['ops-overview', 'investigation-list'], - defaultPageId: 'ops-overview', - contentPadding: 'comfortable', - }, - ], - pages: workspacePages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderHomeWithLayout(); + + const aiWorkbenchToggle = await screen.findByRole('button', { name: 'aiWorkbench' }); + const agentHubToggle = screen.getByRole('button', { name: 'agentHub' }); + expect(screen.queryByRole('button', { name: 'sceneWorkspaces' })).not.toBeInTheDocument(); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); + expect(agentHubToggle).toHaveAttribute('aria-expanded', 'true'); + + await user.click(agentHubToggle); + expect(agentHubToggle).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['agentHub'])); + expect(screen.queryByRole('link', { name: 'agents' })).not.toBeInTheDocument(); + + // The scene partition has nothing to collapse: its menu is one flat list. + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + expect(await screen.findByRole('link', { name: '告警调查' })).toBeInTheDocument(); + expect(sceneMenuLinks()).toEqual(['态势', 'SOC 总览', '告警调查']); + }); + + it('restores the expanded primary group and collapsed agent studio after refresh', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_expanded_primary_nav_section', 'workspace:soc_ui'); + localStorage.setItem('flocks_layout_collapsed_nav_sections', JSON.stringify(['agentHub'])); + mockSocWorkspaceNav(); + + renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); + + expect(await screen.findByRole('link', { name: '告警调查' })).toBeInTheDocument(); + expect(activePartitionName()).toBe('partitionScene'); + }); + + it('renders the SOC workspace in the scene partition and keeps device integration in the agent studio', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); const { container } = renderHomeWithLayout(); - const workspaceLink = await screen.findByRole('link', { name: '场景工作区' }); - expect(workspaceLink).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace', - ); - expect(workspaceLink.querySelectorAll('svg')).toHaveLength(2); - expect(screen.queryByRole('link', { name: '调查列表' })).not.toBeInTheDocument(); - - const sectionHeadings = Array.from(container.querySelectorAll('h3')).map((element) => element.textContent); - expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeGreaterThanOrEqual(0); - expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeLessThan(sectionHeadings.indexOf('agentHub')); - expect(sectionHeadings).not.toContain('systemCenter'); - - const sceneSection = Array.from(container.querySelectorAll('h3')) - .find((heading) => heading.textContent === 'sceneWorkspaces') - ?.parentElement; - expect(sceneSection?.querySelector('a[href="/contracts/webui/workspaces/scene_workspace"]')).not.toBeNull(); - expect(sceneSection?.querySelector('a[href="/devices"]')).not.toBeNull(); - - const agentSection = Array.from(container.querySelectorAll('h3')) - .find((heading) => heading.textContent === 'agentHub') - ?.parentElement; - expect(agentSection?.querySelector('a[href="/devices"]')).toBeNull(); - expect(agentSection?.querySelector('a[href="/models"]')).not.toBeNull(); - expect(agentSection?.querySelector('a[href="/channels"]')).not.toBeNull(); - - await user.click(workspaceLink); - - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - expect(workspaceMenu).toBeInTheDocument(); - expect(workspaceMenu).toHaveClass('w-52'); - expect(workspaceMenu).toHaveClass('bg-zinc-100'); - expect(screen.getByRole('link', { name: '态势' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/risk-dashboard', - ); - expect(screen.getByRole('link', { name: '运营总览' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/ops-overview', - ); - expect(screen.getAllByRole('link', { name: '调查列表' }).find((link) => link.getAttribute('href')?.endsWith('/investigation-list'))).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/scene_workspace/investigation-list', - ); + await screen.findByRole('button', { name: 'aiWorkbench' }); + const agentSection = screen.getByRole('button', { name: 'agentHub' }).closest('div.mb-6') as HTMLElement; + expect(Array.from(agentSection.querySelectorAll('a')).map((link) => link.getAttribute('href'))).toEqual([ + '/agents', + '/skills', + '/tools', + '/devices', + '/hub', + '/models', + '/channels', + ]); + expect(screen.getByRole('link', { name: 'deviceIntegration' })).toHaveAttribute('href', '/devices'); + + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + await screen.findByRole('link', { name: 'SOC 总览' }); + // No group headings and no workspace title: one level only. + expect(sectionHeadings(container)).toEqual([]); + expect(screen.queryByRole('button', { name: 'SOC 工作区' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '告警运营' })).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); + expect(sceneMenuHrefs()).toEqual([ + '/contracts/webui/workspaces/soc_ui/soc-dashboard', + '/contracts/webui/workspaces/soc_ui/soc-overview', + '/contracts/webui/workspaces/soc_ui/soc-alerts', + ]); + // Device integration is not duplicated into the scene partition. + expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); + }); - const workspaceMenuScope = within(workspaceMenu); - const collapseButtons = workspaceMenuScope.getAllByTitle('workspace.collapseSidebar'); - await user.click(collapseButtons[collapseButtons.length - 1]); - expect(screen.queryByRole('link', { name: '运营总览' })).not.toBeInTheDocument(); + it('reorders SOC workspace pages by dragging and keeps the order after refresh', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + const first = renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); + await screen.findByRole('link', { name: '告警调查' }); + expect(navPageOrder(first.container)).toEqual(['soc-dashboard', 'soc-overview', 'soc-alerts']); + + const itemFor = (pageId: string) => first.container.querySelector(`[data-nav-page-id="${pageId}"]`) as HTMLElement; + // The menu is one flat list, so a page can move anywhere in it. + fireEvent.dragStart(itemFor('soc-alerts')); + fireEvent.dragOver(itemFor('soc-dashboard')); + expect(itemFor('soc-dashboard').className).toContain('ring-2'); + fireEvent.drop(itemFor('soc-dashboard')); + fireEvent.dragEnd(itemFor('soc-alerts')); + + expect(JSON.parse(localStorage.getItem('flocks_layout_workspace_page_order:soc_ui') ?? '[]')).toEqual([ + 'soc-alerts', + 'soc-dashboard', + 'soc-overview', + ]); + expect(navPageOrder(first.container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + expect(itemFor('soc-dashboard').className).not.toContain('ring-2'); + + first.unmount(); + const second = renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); + await screen.findByRole('link', { name: '告警调查' }); + expect(navPageOrder(second.container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); + expect(user).toBeDefined(); + }); - await user.click(workspaceMenuScope.getByTitle('workspace.expandSidebar')); - expect(screen.getByRole('link', { name: '运营总览' })).toBeInTheDocument(); + it('moves a SOC workspace page with Alt+Arrow keys', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); - await user.click(workspaceMenuScope.getByRole('button', { name: '调查列表' })); - expect(screen.queryByRole('link', { name: '运营总览' })).not.toBeInTheDocument(); + const { container } = renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); + const alertsLink = await screen.findByRole('link', { name: '告警调查' }); - await user.click(workspaceMenuScope.getByRole('button', { name: '调查列表' })); - expect(screen.getByRole('link', { name: '运营总览' })).toBeInTheDocument(); + fireEvent.keyDown(alertsLink, { key: 'ArrowUp', altKey: true }); + expect(navPageOrder(container)).toEqual(['soc-dashboard', 'soc-alerts', 'soc-overview']); - await user.unhover(workspaceLink); + fireEvent.keyDown(screen.getByRole('link', { name: '告警调查' }), { key: 'ArrowUp', altKey: true }); + expect(navPageOrder(container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); - await waitFor(() => { - expect(screen.queryByRole('navigation', { name: 'workspace.sectionNavigation' })).not.toBeInTheDocument(); - }); + // Without Alt the arrow keys are left alone and nothing moves. + fireEvent.keyDown(screen.getByRole('link', { name: '告警调查' }), { key: 'ArrowDown' }); + expect(navPageOrder(container)).toEqual(['soc-alerts', 'soc-dashboard', 'soc-overview']); }); it('starts a SOC-scoped custom page session from the SOC workspace menu', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); sessionApi.create.mockResolvedValueOnce({ id: 'session-soc-custom-page' }); + mockSocWorkspaceNav(); - const socPages = [ - { - id: 'soc-dashboard', - title: '告警态势', - route: '/contracts/webui/soc-dashboard', - icon: 'Activity', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - { - id: 'soc-alerts', - title: '告警调查', - route: '/contracts/webui/soc-alerts', - icon: 'AlertTriangle', - order: 20, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: socPages, - workspaces: [ - { - id: 'soc_ui', - title: 'SOC 工作区', - route: '/contracts/webui/workspaces/soc_ui', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'soc-alerts', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['soc-dashboard'], - defaultPageId: 'soc-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - { - id: 'operations', - label: '告警运营', - pageIds: ['soc-alerts'], - defaultPageId: 'soc-alerts', - contentPadding: 'none', - }, - ], - pages: socPages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); - - renderHomeWithLayoutAndSessionsRoute(); + renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); - await user.click(await screen.findByRole('link', { name: 'SOC 工作区' })); - - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - const workspaceMenuScope = within(workspaceMenu); - expect(workspaceMenuScope.getByRole('link', { name: '态势' })).toHaveAttribute( + await screen.findByRole('link', { name: '态势' }); + const socSection = sceneMenuSection(); + expect(within(socSection).getByRole('link', { name: '态势' })).toHaveAttribute( 'href', '/contracts/webui/workspaces/soc_ui/soc-dashboard', ); - expect(workspaceMenuScope.getByRole('link', { name: '告警运营' })).toHaveAttribute( - 'href', - '/contracts/webui/workspaces/soc_ui/soc-alerts', - ); - await user.click(workspaceMenuScope.getByRole('button', { name: 'workspace.customPage' })); + await user.click(within(socSection).getByRole('button', { name: 'workspace.customPage' })); await waitFor(() => { expect(sessionApi.create).toHaveBeenCalledWith({ title: 'workspace.customPageSessionTitle' }); }); - expect(await screen.findByTestId('location-probe')).toHaveTextContent( + await waitFor(() => expect(activeProbe()).toHaveTextContent( `/sessions?session=session-soc-custom-page&message=${encodeURIComponent('workspace.socCustomPageInitialMessage')}&display=${encodeURIComponent('workspace.socCustomPageDisplayLabel')}`, - ); + )); + }); + + it('hides the SOC custom page action from members', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + useAuth.mockReturnValue({ + user: { + id: 'user-2', + username: 'member', + role: 'member', + status: 'active', + must_reset_password: false, + }, + logout: vi.fn(), + }); + mockSocWorkspaceNav(); + + renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); + + await screen.findByRole('link', { name: '态势' }); + const socSection = sceneMenuSection(); + expect(within(socSection).queryByRole('button', { name: 'workspace.customPage' })).not.toBeInTheDocument(); + expect(within(socSection).getByRole('button', { name: 'workspace.customTitle' })).toBeInTheDocument(); }); it('customizes the SOC dashboard title from the SOC workspace menu', async () => { @@ -1419,59 +1542,14 @@ describe('Layout WebUI contract pages navigation', () => { localStorage.setItem('flocks_onboarding_dismissed', 'true'); const titleChanged = vi.fn(); window.addEventListener('soc-dashboard:title-changed', titleChanged); - - const socPages = [ - { - id: 'soc-dashboard', - title: '告警态势', - route: '/contracts/webui/soc-dashboard', - icon: 'Activity', - order: 10, - enabled: true, - placement: 'home.after', - buildHash: 'ready', - buildStatus: 'ready' as const, - workspaceId: 'soc_ui', - workspaceTitle: 'SOC 工作区', - workspaceRoute: '/contracts/webui/workspaces/soc_ui', - }, - ]; - useWebUIContractPages.mockReturnValue({ - pages: socPages, - workspaces: [ - { - id: 'soc_ui', - title: 'SOC 工作区', - route: '/contracts/webui/workspaces/soc_ui', - icon: 'ShieldCheck', - order: 10, - enabled: true, - placement: 'sceneWorkspace', - defaultPageId: 'soc-dashboard', - sections: [ - { - id: 'posture', - label: '态势', - pageIds: ['soc-dashboard'], - defaultPageId: 'soc-dashboard', - contentPadding: 'none', - themeOverride: 'dark', - }, - ], - pages: socPages, - }, - ], - loading: false, - error: null, - refetch: vi.fn(), - }); + mockSocWorkspaceNav(); try { - renderHomeWithLayout(); + renderLayoutAt('/contracts/webui/workspaces/soc_ui/soc-overview'); - await user.click(await screen.findByRole('link', { name: 'SOC 工作区' })); - const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); - await user.click(within(workspaceMenu).getByRole('button', { name: 'workspace.customTitle' })); + await screen.findByRole('link', { name: '态势' }); + const socSection = sceneMenuSection(); + await user.click(within(socSection).getByRole('button', { name: 'workspace.customTitle' })); const input = screen.getByLabelText('workspace.customTitle'); await user.clear(input); @@ -1487,4 +1565,113 @@ describe('Layout WebUI contract pages navigation', () => { window.removeEventListener('soc-dashboard:title-changed', titleChanged); } }); + + it('returns a menu entry to the page it was last left on', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/workflows/wf-1'); + + await screen.findByRole('button', { name: 'aiWorkbench' }); + expect(screen.getByRole('link', { name: 'workflows' })).toHaveAttribute('href', '/workflows/wf-1'); + + await user.click(screen.getByRole('link', { name: 'go-sessions' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/sessions')); + + await user.click(screen.getByRole('link', { name: 'workflows' })); + await waitFor(() => expect(activeProbe()).toHaveTextContent('/workflows/wf-1')); + expect(JSON.parse(localStorage.getItem('flocks_layout_open_tabs') ?? '[]')).toEqual([ + { href: '/workflows', path: '/workflows/wf-1' }, + { href: '/sessions', path: '/sessions' }, + ]); + }); + + it('restores visited panes after refresh and drops entries that no longer exist', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + localStorage.setItem('flocks_layout_open_tabs', JSON.stringify([ + { href: '/workflows', path: '/workflows/wf-9' }, + { href: '/contracts/webui/workspaces/soc_ui/soc-alerts', path: '/contracts/webui/workspaces/soc_ui/soc-alerts' }, + { href: '/contracts/webui/workspaces/old_ws/gone', path: '/contracts/webui/workspaces/old_ws/gone' }, + ])); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + + const user = userEvent.setup(); + await screen.findByRole('button', { name: 'aiWorkbench' }); + // A stored entry keeps the path it was left on. + expect(screen.getByRole('link', { name: 'workflows' })).toHaveAttribute('href', '/workflows/wf-9'); + + await user.click(screen.getByRole('tab', { name: 'partitionScene' })); + await screen.findByRole('link', { name: '态势' }); + // The entry of a workspace that no longer exists is simply not there. + expect(screen.queryByRole('link', { name: /old_ws/ })).not.toBeInTheDocument(); + }); + + it('reorders AI workbench entries by dragging and keeps the order after refresh', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + const first = renderHomeWithLayout(); + await screen.findByRole('button', { name: 'aiWorkbench' }); + const keysIn = (container: HTMLElement, sectionName: string) => { + const section = within(container).getByRole('button', { name: sectionName }).closest('div.mb-6') as HTMLElement; + return Array.from(section.querySelectorAll('[data-nav-item-key]')).map((element) => element.getAttribute('data-nav-item-key')); + }; + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/sessions', '/workspace', '/tasks', '/workflows']); + expect(keysIn(first.container, 'agentHub')).toEqual(['/agents', '/skills', '/tools', '/devices', '/hub', '/models', '/channels']); + + const itemFor = (key: string) => first.container.querySelector(`[data-nav-item-key="${key}"]`) as HTMLElement; + fireEvent.dragStart(itemFor('/workflows')); + fireEvent.dragOver(itemFor('/sessions')); + fireEvent.drop(itemFor('/sessions')); + fireEvent.dragEnd(itemFor('/workflows')); + + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/workspace', '/tasks']); + expect(JSON.parse(localStorage.getItem('flocks_layout_nav_item_order:aiWorkbench') ?? '[]')) + .toEqual(['/workflows', '/sessions', '/workspace', '/tasks']); + // Other groups are untouched. + expect(keysIn(first.container, 'agentHub')).toEqual(['/agents', '/skills', '/tools', '/devices', '/hub', '/models', '/channels']); + + // Keyboard works for built-in entries as well. + fireEvent.keyDown(within(first.container).getByRole('link', { name: 'tasks' }), { key: 'ArrowUp', altKey: true }); + expect(keysIn(first.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/tasks', '/workspace']); + + first.unmount(); + const second = renderHomeWithLayout(); + await screen.findByRole('button', { name: 'aiWorkbench' }); + expect(keysIn(second.container, 'aiWorkbench')).toEqual(['/workflows', '/sessions', '/tasks', '/workspace']); + }); + + it('keeps a page mounted with its state across a partition switch', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + mockSocWorkspaceNav(); + + renderLayoutAt('/sessions'); + await screen.findByRole('button', { name: 'aiWorkbench' }); + + await user.click(screen.getByRole('button', { name: 'count-up' })); + await user.click(screen.getByRole('button', { name: 'count-up' })); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + + await user.click(screen.getByRole('link', { name: 'go-soc-alerts' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/contracts/webui/workspaces/soc_ui/soc-alerts'); + }); + // The sessions pane is still mounted, just hidden. + const panes = document.querySelectorAll('[data-keep-alive-pane]'); + expect(Array.from(panes).map((pane) => pane.getAttribute('data-keep-alive-pane'))).toEqual(['inactive', 'active']); + expect(panes[0]).toHaveAttribute('aria-hidden', 'true'); + expect(within(panes[0] as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('0'); + + // Coming back through the top bar finds the page exactly as it was left. + await user.click(screen.getByRole('tab', { name: 'partitionAgent' })); + await waitFor(() => { + expect(activeProbe()).toHaveTextContent('/sessions'); + }); + expect(within(activeProbe().parentElement as HTMLElement).getByTestId('probe-count')).toHaveTextContent('2'); + }); }); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index 0a50be13d..bc06b4da2 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import { Outlet, Link, useLocation, matchPath, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate, type RouteObject } from 'react-router-dom'; import { Home, MessageSquare, @@ -20,20 +20,37 @@ import { ServerCog, LogOut, Settings, + LayoutGrid, + Boxes, ArrowUpCircle, RefreshCw, Gauge, + GripVertical, Loader2, + Pencil, + Plus, type LucideIcon, } from 'lucide-react'; import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; -import type { ComponentType, CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'; +import type { + ComponentType, + CSSProperties, + DragEvent as ReactDragEvent, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from 'react'; import { useTranslation } from 'react-i18next'; import { onboardingAPI } from '@/api/onboarding'; // Modals are only rendered after the user clicks/triggers them; pulling them // into the eager Layout chunk costs ~1.7k LOC + i18n keys + lucide icons that // the home page never needs. const COLLAPSED_NAV_SECTIONS_KEY = 'flocks_layout_collapsed_nav_sections'; +const EXPANDED_PRIMARY_NAV_SECTION_KEY = 'flocks_layout_expanded_primary_nav_section'; +const WORKSPACE_NAV_SECTION_PREFIX = 'workspace:'; +const AI_WORKBENCH_NAV_SECTION_ID = 'aiWorkbench'; +const SCENE_NAV_SECTION_ID = 'sceneWorkspaces'; +// Pane used for routes that are not a sidebar entry (they are not kept as tabs). +const TRANSIENT_PANE_HREF = '__current__'; const SIDEBAR_WIDTH_KEY = 'flocks_layout_sidebar_width'; const SIDEBAR_DEFAULT_WIDTH = 208; const SIDEBAR_MIN_WIDTH = 176; @@ -80,6 +97,33 @@ function saveCollapsedNavSectionIds(sectionIds: Set): void { } } +/** + * The AI workbench and each scene workspace (SOC) form an accordion: at most + * one of them shows its second-level menu at a time. `undefined` means nothing + * has been stored yet, `null` means the user collapsed all of them. + */ +function readExpandedPrimaryNavSectionId(): string | null | undefined { + try { + const rawValue = localStorage.getItem(EXPANDED_PRIMARY_NAV_SECTION_KEY); + if (rawValue === null) return undefined; + return rawValue === '' ? null : rawValue; + } catch { + return undefined; + } +} + +function saveExpandedPrimaryNavSectionId(sectionId: string | null): void { + try { + localStorage.setItem(EXPANDED_PRIMARY_NAV_SECTION_KEY, sectionId ?? ''); + } catch { + // Local storage can be unavailable in restricted browser contexts. + } +} + +function workspaceNavSectionId(workspaceId: string): string { + return `${WORKSPACE_NAV_SECTION_PREFIX}${workspaceId}`; +} + function clampSidebarWidth(width: number): number { if (!Number.isFinite(width)) return SIDEBAR_DEFAULT_WIDTH; return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width))); @@ -159,9 +203,26 @@ import { useWebUIContractPages } from '@/hooks/useWebUIContractPages'; import { preloadI18nNamespaces } from '@/i18nResources'; import { resolveWebUIContractPageIcon } from '@/utils/webuiContractPageIcons'; import { - buildWebUIContractWorkspaceSections, + buildWebUIContractWorkspacePageList, getLocalizedWebUIContractTitle, } from '@/utils/webuiContractWorkspaceSections'; +import { useWorkspacePageOrders } from '@/hooks/useWorkspacePageOrders'; +import { moveItem, saveNavItemOrder, saveWorkspacePageOrder } from '@/utils/workspaceNavOrder'; +import { useLayoutOpenTabs } from '@/hooks/useLayoutOpenTabs'; +import { findActiveTabHref, resolveOpenTabs } from '@/utils/layoutTabs'; +import { + AGENT_PARTITION_DEFAULT_PATH, + SETTINGS_PARTITION_DEFAULT_PATH, + readPartitionPaths, + resolveNavPartition, + savePartitionPaths, + type NavPartitionId, + type NavPartitionPaths, +} from '@/utils/navPartitions'; +import { useSettingsSectionGroups } from '@/utils/settingsSections'; +import PartitionTopBar, { type PartitionTopBarItem } from './PartitionTopBar'; +import KeepAlivePanes from './KeepAlivePanes'; +import { contentRoutes as appContentRoutes } from '@/routes/contentRoutes'; import { sessionApi } from '@/api/session'; import { useToast } from '@/components/common/Toast'; import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; @@ -177,15 +238,25 @@ interface LayoutNavItem { name: string; href: string; icon: LucideIcon; - opensWorkspaceMenu?: boolean; - workspaceId?: string; + /** Opens in a new tab instead of routing (Flocks LLM usage portal). */ + external?: boolean; + /** Workspace page id; present for reorderable workspace pages. */ + pageId?: string; } interface LayoutNavSection { id?: string; name: string; + /** Top-bar partition this section belongs to. */ + partition: NavPartitionId; items: LayoutNavItem[]; collapsible?: boolean; + /** Sections in the same accordion group expand one at a time. */ + accordionGroup?: 'primary'; + /** Set for sections that mirror a WebUI contract workspace (e.g. SOC). */ + workspace?: WebUIContractWorkspaceListItem; + /** Only the first group of a workspace carries its actions (自定义页面 …). */ + workspaceActions?: boolean; } function formatProVersion(version?: string | null): string | null { @@ -245,7 +316,12 @@ function isSocWorkspace(workspace: WebUIContractWorkspaceListItem | null): boole || workspace.title.includes('SOC 工作区'); } -export default function Layout() { +interface LayoutProps { + /** Pages rendered inside the layout; defaults to the app routes (tests inject probes). */ + contentRoutes?: RouteObject[]; +} + +export default function Layout({ contentRoutes = appContentRoutes }: LayoutProps = {}) { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); @@ -284,14 +360,28 @@ export default function Layout() { const notificationGateReady = flocksproStatusReady && (!canManageUpdates || hasCompletedUpdateCheck); const canCreateWorkspaceCustomPage = user?.role === 'admin'; - const { pages: webuiContractPages, workspaces: webuiContractWorkspaces = [] } = useWebUIContractPages(); - const [openWorkspaceMenuId, setOpenWorkspaceMenuId] = useState(null); + const { + pages: webuiContractPages, + workspaces: webuiContractWorkspaces = [], + loading: webuiContractNavLoading, + } = useWebUIContractPages(); + const workspacePageOrders = useWorkspacePageOrders(); + const [openTabRecords, setOpenTabRecords] = useLayoutOpenTabs(); + // Tab whose close is in flight: React Router commits the navigation as a + // transition, so until the route changes it must not be re-added. + const openTabRecordsRef = useRef(openTabRecords); + useEffect(() => { + openTabRecordsRef.current = openTabRecords; + }, [openTabRecords]); const [collapsedNavSectionIds, setCollapsedNavSectionIds] = useState>(readCollapsedNavSectionIds); - const [collapsedWorkspaceSectionIds, setCollapsedWorkspaceSectionIds] = useState>(() => new Set()); + const [expandedPrimaryNavSectionId, setExpandedPrimaryNavSectionId] = useState( + readExpandedPrimaryNavSectionId, + ); + const [draggingNavItem, setDraggingNavItem] = useState<{ sectionId: string; key: string } | null>(null); + const [dragOverNavItemKey, setDragOverNavItemKey] = useState(null); const [creatingWorkspaceCustomPageSession, setCreatingWorkspaceCustomPageSession] = useState(false); const [socTitleDialogOpen, setSocTitleDialogOpen] = useState(false); const [socTitleDraft, setSocTitleDraft] = useState(readSocDashboardTitle); - const workspaceMenuCloseTimerRef = useRef(null); const hasCustomDisplayName = Boolean(configuredDisplayName?.trim()); const expandedSidebarWidth = sidebarWidth; const sidebarOffsetStyle = { @@ -655,77 +745,168 @@ export default function Layout() { // stable as long as the language doesn't change. Without this, every route // switch rebuilt the whole nav structure and cascaded re-renders down to // every , contributing to perceptible navigation lag. + const { groups: settingsGroups } = useSettingsSectionGroups(); + const navigation = useMemo( () => { - const sceneWorkspaceItems = webuiContractWorkspaces - .filter((workspace) => workspace.enabled && (workspace.placement === 'sceneWorkspace' || workspace.placement === 'aiWorkbench')) - .map((workspace) => ({ - name: getLocalizedWebUIContractTitle(workspace, i18n.language), - href: workspace.route, - icon: resolveWebUIContractPageIcon(workspace.icon), - opensWorkspaceMenu: true, - workspaceId: workspace.id, + const enabledWorkspaces = webuiContractWorkspaces.filter((workspace) => workspace.enabled); + const workspacePageItems = (workspace: WebUIContractWorkspaceListItem): LayoutNavItem[] => ( + workspacePageOrders + .apply(workspace.id, buildWebUIContractWorkspacePageList(workspace, i18n.language)) + .map((page) => ({ + pageId: page.id, + name: page.title, + href: `${workspace.route}/${page.id}`, + icon: resolveWebUIContractPageIcon(page.icon), + })) + ); + const workbenchWorkspaceItems = enabledWorkspaces + .filter((workspace) => workspace.placement === 'aiWorkbench') + .flatMap(workspacePageItems); + // Built-in groups can be reordered by dragging too; the order is stored per group. + const navItemKey = (item: LayoutNavItem) => item.pageId ?? item.href; + const orderedGroup = (sectionId: string, items: LayoutNavItem[]) => workspacePageOrders.applyNav(sectionId, items, navItemKey); + // The SOC workspace partition has one flat menu: every enabled scene + // workspace contributes its pages at the same level, no group headings. + const sceneWorkspaces = enabledWorkspaces.filter((workspace) => workspace.placement === 'sceneWorkspace'); + const scenePageItems = sceneWorkspaces.flatMap(workspacePageItems); + const soleSceneWorkspace = sceneWorkspaces.length === 1 ? sceneWorkspaces[0] : undefined; + const sceneWorkspaceSections: LayoutNavSection[] = scenePageItems.length > 0 + ? [{ + id: SCENE_NAV_SECTION_ID, + name: '', + partition: 'scene' as const, + // Nameless, so no heading renders; still "collapsible" so the entries + // stay drag-reorderable. + collapsible: true, + workspace: soleSceneWorkspace, + workspaceActions: Boolean(soleSceneWorkspace), + items: soleSceneWorkspace + ? scenePageItems + : orderedGroup(SCENE_NAV_SECTION_ID, scenePageItems), + }] + : []; + // Custom pages that do not belong to a workspace sit next to the scene + // workspaces rather than under the home entry. + const customPageItems: LayoutNavItem[] = webuiContractPages + .filter((page) => !page.workspaceId && page.enabled && page.placement === 'home.after' && page.buildStatus === 'ready') + .map((page) => ({ + name: getLocalizedWebUIContractTitle(page, i18n.language), + href: page.route, + icon: resolveWebUIContractPageIcon(page.icon), })); + // The system-settings partition reuses the settings page sections. + const settingsSections: LayoutNavSection[] = settingsGroups.map((group) => ({ + id: `settings:${group.id}`, + name: group.name, + partition: 'settings' as const, + items: [ + ...group.items.map((item) => ({ + name: item.name, + href: `/settings/${item.id}`, + icon: item.icon, + })), + // The usage portal lives outside the app but belongs to this menu. + ...(group.id === 'system' + ? [{ name: t('flocksLlmUsageQuota'), href: FLOCKS_LLM_USAGE_URL, icon: Gauge, external: true }] + : []), + ], + })); return [ { name: '', - items: [ - { name: t('flocksHome'), href: '/', icon: Home }, - ...webuiContractPages - .filter((page) => !page.workspaceId && page.enabled && page.placement === 'home.after' && page.buildStatus === 'ready') - .map((page) => ({ - name: getLocalizedWebUIContractTitle(page, i18n.language), - href: page.route, - icon: resolveWebUIContractPageIcon(page.icon), - })), - ], + partition: 'agent', + items: [{ name: t('flocksHome'), href: '/', icon: Home }], }, { - id: 'aiWorkbench', + id: AI_WORKBENCH_NAV_SECTION_ID, name: t('aiWorkbench'), + partition: 'agent', collapsible: true, - items: [ + accordionGroup: 'primary', + items: orderedGroup(AI_WORKBENCH_NAV_SECTION_ID, [ { name: t('sessions'), href: '/sessions', icon: MessageSquare }, { name: t('workspace'), href: '/workspace', icon: FolderOpen }, { name: t('tasks'), href: '/tasks', icon: ListTodo }, { name: t('workflows'), href: '/workflows', icon: Workflow }, - ], - }, - { - id: 'sceneWorkspaces', - name: t('sceneWorkspaces'), - collapsible: true, - items: [ - ...sceneWorkspaceItems, - { name: t('deviceIntegration'), href: '/devices', icon: ServerCog }, - ], + ...workbenchWorkspaceItems, + ]), }, { id: 'agentHub', name: t('agentHub'), + partition: 'agent', collapsible: true, - items: [ + items: orderedGroup('agentHub', [ { name: t('agents'), href: '/agents', icon: Bot }, { name: t('skills'), href: '/skills', icon: BookOpen }, { name: t('tools'), href: '/tools', icon: Wrench }, + { name: t('deviceIntegration'), href: '/devices', icon: ServerCog }, { name: t('hub', { productName }), href: '/hub', icon: Archive }, { name: t('models'), href: '/models', icon: Brain }, { name: t('channels'), href: '/channels', icon: Radio }, - ], + ]), }, + ...sceneWorkspaceSections, + ...(customPageItems.length > 0 + ? [{ id: 'customPages', name: t('customPages'), partition: 'scene' as const, collapsible: true, items: customPageItems }] + : []), + ...settingsSections, ]; }, - [i18n.language, productName, webuiContractPages, webuiContractWorkspaces, t], + [i18n.language, productName, settingsGroups, webuiContractPages, webuiContractWorkspaces, workspacePageOrders, t], ); - const isFullScreenPage = - matchPath('/workflows/create', location.pathname) || - matchPath('/workflows/:id/edit', location.pathname) || - matchPath('/workflows/:id', location.pathname) || - matchPath('/sessions', location.pathname) || - matchPath('/devices', location.pathname) || - matchPath('/contracts/webui/*', location.pathname); + // Which of the three top-bar partitions is showing. It follows the route, but + // a partition with no page yet (SOC not installed) can still be selected to + // show its own — empty — menu. + const routePartition = resolveNavPartition(location.pathname); + const [selectedPartition, setSelectedPartition] = useState(routePartition); + useEffect(() => { + setSelectedPartition(routePartition); + }, [routePartition]); + + const visibleNavigation = useMemo( + () => navigation.filter((section) => section.partition === selectedPartition), + [navigation, selectedPartition], + ); + + const partitionItems = useMemo(() => [ + { id: 'agent', name: t('partitionAgent'), icon: Sparkles }, + { id: 'scene', name: t('partitionScene'), icon: LayoutGrid }, + { id: 'settings', name: t('partitionSettings'), icon: Settings }, + ], [t]); + + // Remember where each partition was left so the top bar returns to it. + const partitionPathsRef = useRef(readPartitionPaths()); + useEffect(() => { + const next = { ...partitionPathsRef.current, [routePartition]: `${location.pathname}${location.search}` }; + partitionPathsRef.current = next; + savePartitionPaths(next); + }, [location.pathname, location.search, routePartition]); + + const partitionTargetPath = useCallback((id: NavPartitionId): string | null => { + const hrefs = navigation + .filter((section) => section.partition === id) + .flatMap((section) => section.items) + .map((item) => item.href); + const stored = partitionPathsRef.current[id]; + // Only reuse the stored page while it still exists in that partition. + if (stored && findActiveTabHref(hrefs, stored.split('?')[0])) return stored; + if (id === 'agent') return AGENT_PARTITION_DEFAULT_PATH; + if (id === 'settings') return SETTINGS_PARTITION_DEFAULT_PATH; + return hrefs[0] ?? null; + }, [navigation]); + + const selectPartition = useCallback((id: NavPartitionId) => { + setSelectedPartition(id); + setSidebarOpen(false); + if (id === routePartition) return; + const target = partitionTargetPath(id); + if (target) navigate(target); + }, [navigate, partitionTargetPath, routePartition]); + const displayVersion = isFlocksproActive ? updateInfo?.edition === 'flockspro' ? formatProVersion(currentProductVersion(updateInfo, true)) @@ -746,54 +927,114 @@ export default function Layout() { hash: location.hash, }, }; - const activeWorkspaceMenu = useMemo( - () => webuiContractWorkspaces.find((workspace) => workspace.id === openWorkspaceMenuId && workspace.enabled) ?? null, - [openWorkspaceMenuId, webuiContractWorkspaces], + const isNavItemActive = useCallback((href: string) => ( + location.pathname === href || (href !== '/' && location.pathname.startsWith(`${href}/`)) + ), [location.pathname]); + + // Browser-style tabs above the content: every sidebar entry visited becomes + // a tab (home, AI workbench pages, agent studio pages, SOC pages alike). + const navItemsFlat = useMemo(() => navigation.flatMap((section) => section.items), [navigation]); + const activeTabHref = useMemo( + () => findActiveTabHref(navItemsFlat.map((item) => item.href), location.pathname), + [location.pathname, navItemsFlat], ); - const ActiveWorkspaceMenuIcon = activeWorkspaceMenu - ? resolveWebUIContractPageIcon(activeWorkspaceMenu.icon) - : null; - const activeWorkspaceSections = useMemo( - () => (activeWorkspaceMenu ? buildWebUIContractWorkspaceSections(activeWorkspaceMenu, i18n.language) : []), - [activeWorkspaceMenu, i18n.language], - ); - const activeWorkspaceMenuTitle = activeWorkspaceMenu - ? getLocalizedWebUIContractTitle(activeWorkspaceMenu, i18n.language) - : ''; - const showWorkspaceCustomPageAction = canCreateWorkspaceCustomPage && isSocWorkspace(activeWorkspaceMenu); - const showSocDashboardTitleAction = isSocWorkspace(activeWorkspaceMenu); - - const cancelWorkspaceMenuClose = useCallback(() => { - if (workspaceMenuCloseTimerRef.current === null) return; - window.clearTimeout(workspaceMenuCloseTimerRef.current); - workspaceMenuCloseTimerRef.current = null; - }, []); + const currentLocationPath = `${location.pathname}${location.search}`; - const openWorkspaceMenu = useCallback((workspaceId?: string) => { - if (!workspaceId) return; - cancelWorkspaceMenuClose(); - setOpenWorkspaceMenuId(workspaceId); - }, [cancelWorkspaceMenuClose]); - - const scheduleWorkspaceMenuClose = useCallback(() => { - cancelWorkspaceMenuClose(); - workspaceMenuCloseTimerRef.current = window.setTimeout(() => { - setOpenWorkspaceMenuId(null); - workspaceMenuCloseTimerRef.current = null; - }, 120); - }, [cancelWorkspaceMenuClose]); + useEffect(() => { + if (!activeTabHref) return; + const stored = openTabRecordsRef.current; + const existing = stored.find((record) => record.href === activeTabHref); + if (existing && existing.path === currentLocationPath) return; + setOpenTabRecords(existing + ? stored.map((record) => (record.href === activeTabHref ? { ...record, path: currentLocationPath } : record)) + : [...stored, { href: activeTabHref, path: currentLocationPath }]); + }, [activeTabHref, currentLocationPath, setOpenTabRecords]); + + // Every menu entry that has been visited keeps its own pane, in any + // partition, so coming back to it restores the page as it was left. + const openTabs = useMemo(() => { + const tabs = resolveOpenTabs(navItemsFlat, openTabRecords) + .map((tab) => ({ href: tab.href, path: tab.path, name: tab.name, icon: tab.icon })); + if (activeTabHref && !tabs.some((tab) => tab.href === activeTabHref)) { + const item = navItemsFlat.find((entry) => entry.href === activeTabHref); + if (item) tabs.push({ href: item.href, path: currentLocationPath, name: item.name, icon: item.icon }); + } + return tabs; + }, [activeTabHref, currentLocationPath, navItemsFlat, openTabRecords]); + + // One pane per visited entry stays mounted; a route that is not a menu entry + // gets a transient pane that goes away when it is left. + const keepAlivePanes = useMemo(() => { + const panes = openTabs.map((tab) => ({ href: tab.href })); + return activeTabHref ? panes : [...panes, { href: TRANSIENT_PANE_HREF }]; + }, [activeTabHref, openTabs]); + const activePaneHref = activeTabHref ?? TRANSIENT_PANE_HREF; + + const openTabPathByHref = useMemo(() => { + const map = new Map(); + for (const tab of openTabs) map.set(tab.href, tab.path); + return map; + }, [openTabs]); + const navItemPath = useCallback( + (href: string) => openTabPathByHref.get(href) ?? href, + [openTabPathByHref], + ); - useEffect(() => () => cancelWorkspaceMenuClose(), [cancelWorkspaceMenuClose]); + const primaryNavSectionIds = useMemo( + () => visibleNavigation + .filter((section) => section.accordionGroup === 'primary' && section.id) + .map((section) => section.id as string), + [visibleNavigation], + ); + // The accordion section that owns the current route (null for home, settings, agent studio...). + const routePrimaryNavSectionId = useMemo(() => { + const owner = visibleNavigation.find((section) => ( + section.accordionGroup === 'primary' + && section.id + && section.items.some((item) => isNavItemActive(item.href)) + )); + return owner?.id ?? null; + }, [isNavItemActive, visibleNavigation]); + + const effectiveExpandedPrimaryNavSectionId = useMemo(() => { + if (expandedPrimaryNavSectionId === null) return null; + if (expandedPrimaryNavSectionId !== undefined) { + if (primaryNavSectionIds.includes(expandedPrimaryNavSectionId)) return expandedPrimaryNavSectionId; + // A stored workspace section that has not loaded yet: keep everything + // collapsed instead of flashing the AI workbench open. + if (webuiContractNavLoading && expandedPrimaryNavSectionId.startsWith(WORKSPACE_NAV_SECTION_PREFIX)) return null; + } + // Fall back to the first accordion section of the partition on screen, so + // switching partitions never lands on an all-collapsed menu. + return routePrimaryNavSectionId ?? primaryNavSectionIds[0] ?? null; + }, [expandedPrimaryNavSectionId, primaryNavSectionIds, routePrimaryNavSectionId, webuiContractNavLoading]); + + // Mirrors the explicit (stored) choice so the route effect below can compare + // against it without re-running on every render. + const explicitExpandedPrimaryNavSectionRef = useRef(expandedPrimaryNavSectionId); useEffect(() => { - setCollapsedWorkspaceSectionIds(new Set()); - }, [openWorkspaceMenuId]); + explicitExpandedPrimaryNavSectionRef.current = expandedPrimaryNavSectionId; + }, [expandedPrimaryNavSectionId]); + // Entering a route owned by one accordion section opens that section and + // closes the other: people work in either the AI workbench or a scene + // workspace such as SOC, rarely both at once. The choice is persisted so + // leaving for the home page keeps the last group open. useEffect(() => { - if (openWorkspaceMenuId && !activeWorkspaceMenu) { - setOpenWorkspaceMenuId(null); - } - }, [activeWorkspaceMenu, openWorkspaceMenuId]); + if (!routePrimaryNavSectionId) return; + if (explicitExpandedPrimaryNavSectionRef.current === routePrimaryNavSectionId) return; + explicitExpandedPrimaryNavSectionRef.current = routePrimaryNavSectionId; + setExpandedPrimaryNavSectionId(routePrimaryNavSectionId); + saveExpandedPrimaryNavSectionId(routePrimaryNavSectionId); + }, [routePrimaryNavSectionId]); + + const togglePrimaryNavSection = useCallback((sectionId: string) => { + const next = effectiveExpandedPrimaryNavSectionId === sectionId ? null : sectionId; + explicitExpandedPrimaryNavSectionRef.current = next; + setExpandedPrimaryNavSectionId(next); + saveExpandedPrimaryNavSectionId(next); + }, [effectiveExpandedPrimaryNavSectionId]); const toggleNavSection = useCallback((sectionId: string) => { setCollapsedNavSectionIds((current) => { @@ -808,34 +1049,88 @@ export default function Layout() { }); }, []); - const toggleWorkspaceSection = useCallback((sectionId: string) => { - setCollapsedWorkspaceSectionIds((current) => { - const next = new Set(current); - if (next.has(sectionId)) { - next.delete(sectionId); - } else { - next.add(sectionId); - } - return next; - }); + // Second-level menu order is customisable in every group: workspace pages are + // keyed by page id, built-in entries by href, each stored per group. + const sectionItemKeys = (section: LayoutNavSection) => section.items.map((item) => item.pageId ?? item.href); + + const reorderSectionItems = useCallback((section: LayoutNavSection, fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const keys = sectionItemKeys(section); + const fromIndex = keys.indexOf(fromKey); + const toIndex = keys.indexOf(toKey); + if (fromIndex < 0 || toIndex < 0) return; + const nextKeys = moveItem(keys, fromIndex, toIndex); + if (section.workspace) { + saveWorkspacePageOrder(section.workspace.id, nextKeys); + } else if (section.id) { + saveNavItemOrder(section.id, nextKeys); + } }, []); - const handleCreateWorkspaceCustomPage = useCallback(async () => { - if (!activeWorkspaceMenu || creatingWorkspaceCustomPageSession) return; + const handleNavItemDragStart = useCallback((event: ReactDragEvent, sectionId: string, key: string) => { + setDraggingNavItem({ sectionId, key }); + setDragOverNavItemKey(null); + try { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', key); + } catch { + // Some environments restrict dataTransfer; dragging still works through state. + } + }, []); + + const handleNavItemDragOver = useCallback((event: ReactDragEvent, sectionId: string, key: string) => { + if (!draggingNavItem || draggingNavItem.sectionId !== sectionId) return; + event.preventDefault(); + try { + event.dataTransfer.dropEffect = 'move'; + } catch { + // Ignore dataTransfer restrictions. + } + if (dragOverNavItemKey !== key) { + setDragOverNavItemKey(key); + } + }, [dragOverNavItemKey, draggingNavItem]); + + const handleNavItemDrop = useCallback((event: ReactDragEvent, section: LayoutNavSection, key: string) => { + if (!draggingNavItem || draggingNavItem.sectionId !== (section.id ?? '')) return; + event.preventDefault(); + reorderSectionItems(section, draggingNavItem.key, key); + setDraggingNavItem(null); + setDragOverNavItemKey(null); + }, [draggingNavItem, reorderSectionItems]); + + const handleNavItemDragEnd = useCallback(() => { + setDraggingNavItem(null); + setDragOverNavItemKey(null); + }, []); + + // Keyboard alternative to dragging: Alt+ArrowUp / Alt+ArrowDown moves the focused entry. + const handleNavItemReorderKeyDown = useCallback((event: ReactKeyboardEvent, section: LayoutNavSection, key: string) => { + if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; + const keys = sectionItemKeys(section); + const index = keys.indexOf(key); + const targetIndex = index + (event.key === 'ArrowUp' ? -1 : 1); + if (index < 0 || targetIndex < 0 || targetIndex >= keys.length) return; + event.preventDefault(); + reorderSectionItems(section, key, keys[targetIndex]); + }, [reorderSectionItems]); + + const handleCreateWorkspaceCustomPage = useCallback(async (workspace: WebUIContractWorkspaceListItem) => { + if (creatingWorkspaceCustomPageSession) return; + const workspaceTitle = getLocalizedWebUIContractTitle(workspace, i18n.language); setCreatingWorkspaceCustomPageSession(true); try { const session = await sessionApi.create({ title: tWebUIContractPage('workspace.customPageSessionTitle', { - workspace: activeWorkspaceMenuTitle, + workspace: workspaceTitle, }), }); const message = tWebUIContractPage('workspace.socCustomPageInitialMessage', { - workspaceId: activeWorkspaceMenu.id, - workspaceTitle: activeWorkspaceMenuTitle, - workspaceRoute: activeWorkspaceMenu.route, + workspaceId: workspace.id, + workspaceTitle, + workspaceRoute: workspace.route, }); const displayLabel = tWebUIContractPage('workspace.socCustomPageDisplayLabel'); - setOpenWorkspaceMenuId(null); setSidebarOpen(false); navigate( `/sessions?session=${session.id}&message=${encodeURIComponent(message)}&display=${encodeURIComponent(displayLabel)}`, @@ -847,9 +1142,8 @@ export default function Layout() { setCreatingWorkspaceCustomPageSession(false); } }, [ - activeWorkspaceMenu, - activeWorkspaceMenuTitle, creatingWorkspaceCustomPageSession, + i18n.language, navigate, tWebUIContractPage, toast, @@ -858,7 +1152,6 @@ export default function Layout() { const openSocTitleDialog = useCallback(() => { setSocTitleDraft(readSocDashboardTitle()); setSocTitleDialogOpen(true); - setOpenWorkspaceMenuId(null); }, []); const closeSocTitleDialog = useCallback(() => { @@ -1049,10 +1342,20 @@ export default function Layout() { {/* Navigation */}