Compare commits

...
8 Commits
11 changed files with 873 additions and 92 deletions
+25
View File
@@ -334,6 +334,31 @@ describe('same-origin canary gateway', () => {
await reader?.cancel();
});
it('flushes SSE response headers before the first upstream event', async () => {
const upstream = createServer((_request, response) => {
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
response.flushHeaders();
});
servers.push(upstream);
const gateway = await startGateway(await fixtureDist(), await listen(upstream));
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 500);
try {
const response = await fetch(`${gateway.origin}/api/v1/events`, {
signal: controller.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
await response.body?.cancel();
} finally {
clearTimeout(timeout);
}
});
it('has reversible, idempotent start/stop lifecycle', async () => {
const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 });
gateways.push(gateway);
+1
View File
@@ -177,6 +177,7 @@ async function proxyRequest(
upstreamResponse.statusMessage,
withoutHopByHop(upstreamResponse.headers),
);
response.flushHeaders();
pipeline(upstreamResponse, response)
.then(resolvePromise)
.catch(() => {
+28 -10
View File
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppShell, type InstanceContext } from './app-shell.js';
import { AppShell, resolveRoute, type InstanceContext } from './app-shell.js';
import type { AuditDataSource } from './audit/audit-page.js';
import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js';
import type { EventStreamClient } from './events/event-stream-client.js';
@@ -203,7 +203,7 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(bravo).getByText('需要认证')).toBeTruthy();
});
it('keeps every global workspace reachable and exposes settings subsections', async () => {
it('keeps every global workspace reachable and links settings directly to security', async () => {
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
const scheduleDataSource: ScheduleDataSource = {
listSchedules: vi.fn().mockResolvedValue([]),
@@ -234,7 +234,7 @@ describe('React AppShell and Fleet vertical slice', () => {
'/automation',
);
expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
'/settings/instances',
'/settings/system',
);
expect(
within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'),
@@ -254,13 +254,31 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
const settingsNavigation = screen.getByRole('navigation', { name: '设置导航' });
expect(within(settingsNavigation).getByRole('link', { name: '实例管理' })).toBeTruthy();
expect(
within(settingsNavigation)
.getByRole('link', { name: '系统与安全' })
.getAttribute('aria-current'),
).toBe('page');
expect(screen.getByRole('heading', { name: '密码保护' })).toBeTruthy();
expect(screen.queryByRole('navigation', { name: '设置导航' })).toBeNull();
});
it('redirects legacy top-level instance settings without loading Fleet', () => {
expect(resolveRoute('/settings/instances')).toMatchObject({
kind: 'redirect',
to: '/settings/system',
});
expect(resolveRoute('/settings/instances/bravo')).toMatchObject({
kind: 'settings-instance-detail',
params: { instanceId: 'bravo' },
});
const load = vi.fn().mockResolvedValue(snapshot);
render(
<AppShell
pathname="/settings/instances"
fleetDataSource={source(load)}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(screen.getByRole('heading', { name: '密码保护' })).toBeTruthy();
expect(load).not.toHaveBeenCalled();
});
it('hides placeholder dev version badge in the top bar', () => {
+3 -29
View File
@@ -40,7 +40,6 @@ import {
type NotificationsDataSource,
} from './instances/notifications-module.js';
import { OtaModule, type OtaDataSource } from './instances/ota-module.js';
import { InstanceSettingsPage } from './settings/instance-settings-page.js';
import {
InstanceDetail,
INSTANCE_MODULE_LABELS,
@@ -69,7 +68,6 @@ export type RouteKind =
| 'job-detail'
| 'audit'
| 'audit-detail'
| 'settings-instances'
| 'settings-instance-detail'
| 'settings-system'
| 'not-found';
@@ -139,13 +137,14 @@ function decode(value: string): string {
export function resolveRoute(input: string): ResolvedRoute {
const pathname = normalize(input);
if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' };
if (pathname === '/settings/instances')
return { kind: 'redirect', pathname, to: '/settings/system' };
const staticRoutes: Readonly<Record<string, RouteKind>> = {
'/fleet': 'fleet',
'/automation': 'automation',
'/instances/new': 'instance-new',
'/jobs': 'jobs',
'/audit': 'audit',
'/settings/instances': 'settings-instances',
'/settings/system': 'settings-system',
};
if (staticRoutes[pathname]) return { kind: staticRoutes[pathname], pathname };
@@ -270,14 +269,6 @@ function Page({
}
/>
);
if (route.kind === 'settings-instances')
return (
<InstanceSettingsPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})}
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'settings-system') return <ConsoleAuthSettings />;
if (route.kind === 'not-found')
return (
@@ -405,7 +396,6 @@ function Page({
'job-detail': '任务详情',
audit: '审计',
'audit-detail': '审计事件',
'settings-instances': '实例设置',
'settings-instance-detail': '实例设置',
'settings-system': '系统设置',
};
@@ -432,7 +422,7 @@ const GLOBAL_NAVIGATION: readonly {
}[] = [
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
{ section: 'settings', href: '/settings/instances', label: '设置', icon: 'settings' },
{ section: 'settings', href: '/settings/system', label: '设置', icon: 'settings' },
];
const STREAM_LABELS = {
@@ -693,22 +683,6 @@ export function AppShell({
</header>
<div className="app-layout app-layout-single">
<main id="main-content" tabIndex={-1}>
{currentSection === 'settings' ? (
<nav className="settings-navigation" aria-label="设置导航">
<a
href="/settings/instances"
aria-current={route.kind === 'settings-system' ? undefined : 'page'}
>
</a>
<a
href="/settings/system"
aria-current={route.kind === 'settings-system' ? 'page' : undefined}
>
</a>
</nav>
) : null}
{routeInstanceId && instanceLoading ? (
<p role="status" aria-label="实例加载状态">
+17 -4
View File
@@ -3,16 +3,29 @@ import { createRoot } from 'react-dom/client';
import 'animal-island-ui/style';
import { ConsoleAuthGate } from './auth/console-auth.js';
import { AppShell } from './app-shell.js';
import { AppShell, resolveRoute } from './app-shell.js';
import { useBrowserPathname } from './navigation/use-browser-pathname.js';
import './styles.css';
const root = document.querySelector<HTMLElement>('#root');
if (!root) throw new Error('Missing application root');
function resolveBrowserPathname(pathname: string): string {
const route = resolveRoute(pathname);
return route.kind === 'redirect' ? (route.to ?? pathname) : pathname;
}
function BrowserApp() {
const pathname = useBrowserPathname(resolveBrowserPathname);
return (
<ConsoleAuthGate>
<AppShell pathname={pathname} />
</ConsoleAuthGate>
);
}
createRoot(root).render(
<StrictMode>
<ConsoleAuthGate>
<AppShell pathname={window.location.pathname} />
</ConsoleAuthGate>
<BrowserApp />
</StrictMode>,
);
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useBrowserPathname } from './use-browser-pathname.js';
function Harness({
link = <a href="/automation">Automation</a>,
onMainFocus,
resolvePathname,
}: {
link?: React.ReactNode;
onMainFocus?: (pathname: string) => void;
resolvePathname?: (pathname: string) => string;
}) {
const pathname = useBrowserPathname(resolvePathname);
return (
<>
{link}
<main id="main-content" tabIndex={-1} onFocus={() => onMainFocus?.(pathname)}>
{pathname}
</main>
</>
);
}
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
vi.restoreAllMocks();
});
describe('useBrowserPathname', () => {
it('pushes eligible same-origin links and updates pathname without reloading', () => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
const focusedPathnames: string[] = [];
render(<Harness onMainFocus={(pathname) => focusedPathnames.push(pathname)} />);
const link = screen.getByRole('link', { name: 'Automation' });
const accepted = fireEvent.click(link);
expect(accepted).toBe(false);
expect(pushState).toHaveBeenCalledWith(null, '', '/automation');
expect(screen.getByRole('main').textContent).toBe('/automation');
expect(document.activeElement).toBe(screen.getByRole('main'));
expect(focusedPathnames).toEqual(['/automation']);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
});
it('replaces a legacy pathname with its canonical destination', () => {
window.history.replaceState(null, '', '/settings/instances');
const replaceState = vi.spyOn(window.history, 'replaceState');
render(
<Harness
resolvePathname={(pathname) =>
pathname === '/settings/instances' ? '/settings/system' : pathname
}
/>,
);
expect(replaceState).toHaveBeenCalledWith(null, '', '/settings/system');
expect(window.location.pathname).toBe('/settings/system');
expect(screen.getByRole('main').textContent).toBe('/settings/system');
});
it('makes an exact-current link a no-op without adding history', () => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
render(<Harness link={<a href="/fleet">Current</a>} />);
const accepted = fireEvent.click(screen.getByRole('link', { name: 'Current' }));
expect(accepted).toBe(false);
expect(pushState).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/fleet');
});
it('tracks browser history traversal and restores the page start', () => {
window.history.replaceState(null, '', '/automation');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
render(<Harness />);
window.history.replaceState(null, '', '/fleet');
fireEvent(window, new PopStateEvent('popstate'));
expect(screen.getByRole('main').textContent).toBe('/fleet');
expect(document.activeElement).toBe(screen.getByRole('main'));
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
});
it.each([
[
'target window',
<a href="/automation" target="_blank">
Excluded
</a>,
{},
],
[
'download',
<a href="/automation" download>
Excluded
</a>,
{},
],
['external origin', <a href="https://example.com/automation">Excluded</a>, {}],
['hash-only navigation', <a href="/fleet#cards">Excluded</a>, {}],
['middle click', <a href="/automation">Excluded</a>, { button: 1 }],
['meta click', <a href="/automation">Excluded</a>, { metaKey: true }],
['control click', <a href="/automation">Excluded</a>, { ctrlKey: true }],
['shift click', <a href="/automation">Excluded</a>, { shiftKey: true }],
['alt click', <a href="/automation">Excluded</a>, { altKey: true }],
])('leaves %s to the browser', (_label, link, eventInit) => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
render(<Harness link={link} />);
let hookPrevented = true;
const suppressBrowserNavigation = (event: MouseEvent) => {
hookPrevented = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener('click', suppressBrowserNavigation, { once: true });
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...eventInit });
screen.getByRole('link', { name: 'Excluded' }).dispatchEvent(event);
expect(hookPrevented).toBe(false);
expect(pushState).not.toHaveBeenCalled();
expect(screen.getByRole('main').textContent).toBe('/fleet');
});
});
@@ -0,0 +1,86 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export type PathnameResolver = (pathname: string) => string;
const keepPathname: PathnameResolver = (pathname) => pathname;
function currentRelativeLocation(): string {
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function resolvedLocation(location: string, resolvePathname: PathnameResolver): string {
const url = new URL(location, window.location.origin);
const pathname = resolvePathname(url.pathname);
return `${pathname}${url.search}${url.hash}`;
}
function restorePageStart(): void {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.querySelector<HTMLElement>('#main-content')?.focus({ preventScroll: true });
}
function isModifiedClick(event: MouseEvent): boolean {
return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
export function useBrowserPathname(resolvePathname: PathnameResolver = keepPathname): string {
const [location, setLocation] = useState(currentRelativeLocation);
const restoreAfterCommit = useRef(false);
const pathname = new URL(location, window.location.origin).pathname;
useLayoutEffect(() => {
const canonicalLocation = resolvedLocation(location, resolvePathname);
if (canonicalLocation !== location) {
window.history.replaceState(window.history.state, '', canonicalLocation);
setLocation(canonicalLocation);
return;
}
if (!restoreAfterCommit.current) return;
restoreAfterCommit.current = false;
restorePageStart();
}, [location, resolvePathname]);
useEffect(() => {
function handleClick(event: MouseEvent): void {
if (event.defaultPrevented || event.button !== 0 || isModifiedClick(event)) return;
if (!(event.target instanceof Element)) return;
const anchor = event.target.closest<HTMLAnchorElement>('a[href]');
if (!anchor || anchor.hasAttribute('download')) return;
if (anchor.target && anchor.target !== '_self') return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
const nextLocation = `${url.pathname}${url.search}${url.hash}`;
if (nextLocation === currentRelativeLocation()) {
event.preventDefault();
return;
}
if (
url.pathname === window.location.pathname &&
url.search === window.location.search &&
url.hash !== window.location.hash
)
return;
event.preventDefault();
window.history.pushState(null, '', nextLocation);
restoreAfterCommit.current = true;
setLocation(currentRelativeLocation());
}
function handlePopState(): void {
restoreAfterCommit.current = true;
setLocation(currentRelativeLocation());
}
document.addEventListener('click', handleClick);
window.addEventListener('popstate', handlePopState);
return () => {
document.removeEventListener('click', handleClick);
window.removeEventListener('popstate', handlePopState);
};
}, []);
return pathname;
}
-44
View File
@@ -341,30 +341,6 @@ main {
background: #fbfcfd;
box-shadow: 0 5px 22px rgba(20, 35, 55, 0.055);
}
.settings-navigation {
display: flex;
gap: 0.3rem;
margin: -0.35rem 0 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--line);
}
.settings-navigation a {
min-height: 2.35rem;
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
color: var(--muted);
border-radius: 7px;
text-decoration: none;
font-size: 0.82rem;
font-weight: 700;
}
.settings-navigation a:hover,
.settings-navigation a[aria-current='page'] {
color: var(--primary);
background: var(--primary-soft);
}
/* Shared page headers */
.fleet-heading,
main > section > header,
@@ -2879,26 +2855,6 @@ main {
box-shadow: none;
}
.settings-navigation {
width: fit-content;
gap: 0.35rem;
margin: 0 0 1.6rem;
padding: 0.35rem;
border: 2px solid #d8cdb7;
border-radius: 999px;
background: #fffdf5;
}
.settings-navigation a {
border-radius: 999px;
}
.settings-navigation a:hover,
.settings-navigation a[aria-current='page'] {
color: #725d42;
background: #fff0ae;
}
.fleet-heading,
main > section > header,
.page-heading {
@@ -0,0 +1,456 @@
# Settings Navigation And Stream Performance Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make top-level settings security-only, switch internal pages without document reloads, and let the browser observe an idle SSE connection immediately.
**Architecture:** Keep the existing pathname-based `AppShell` and add one focused browser-navigation Hook that owns pathname state, eligible link interception, history traversal, focus, and scroll restoration. Keep the instance editor route intact while turning the legacy top-level instance-settings route into the existing redirect route shape. Preserve streaming proxy behavior and explicitly flush downstream headers as soon as upstream headers arrive.
**Tech Stack:** React 19, TypeScript 5.9, Testing Library, Vitest/jsdom, Node HTTP, Chrome DevTools Protocol browser E2E
## Global Constraints
- Do not add a third-party router.
- Top-level settings contains only password protection and HTTP/HTTPS security information.
- `/settings/instances` resolves as a redirect to `/settings/system`.
- `/settings/instances/:instanceId` remains available for instance editing from node details.
- Eligible same-origin links use `history.pushState`; external, download, target, cross-origin, and modified-click links retain browser behavior.
- Browser back and forward remain functional through `popstate`.
- Page changes focus `#main-content` and scroll to the top.
- `AppShell` remains mounted across top-navigation changes so the event subscription is not recreated.
- SSE remains streaming and authenticated; do not replace it with polling or conceal genuine reconnect states.
- Production code changes must follow an observed failing test.
- Do not push the resulting commits unless the user explicitly requests it.
---
## File Structure
- `apps/web/src/app-shell.tsx`: route ownership and the three-entry global navigation; no browser event ownership.
- `apps/web/src/app-shell.integration.test.tsx`: route, navigation, security-only settings, and instance-editor regression coverage.
- `apps/web/src/navigation/use-browser-pathname.ts`: browser pathname state and document-level navigation interception.
- `apps/web/src/navigation/use-browser-pathname.test.tsx`: jsdom behavior contract for same-page navigation.
- `apps/web/src/main.tsx`: thin browser root that supplies the Hook pathname to the persistent `AppShell`.
- `apps/web/src/styles.css`: remove now-unused top-level settings sub-navigation styles.
- `apps/api/src/canary-gateway.ts`: downstream proxy header forwarding.
- `apps/api/src/canary-gateway.test.ts`: idle SSE header-flush regression.
- `scripts/real-browser-e2e.mjs`: browser-level assertions for navigation persistence, request volume, focus, and live status.
### Task 1: Security-Only Top-Level Settings Route
**Files:**
- Modify: `apps/web/src/app-shell.integration.test.tsx`
- Modify: `apps/web/src/app-shell.tsx`
- Modify: `apps/web/src/styles.css`
**Interfaces:**
- Consumes: existing `resolveRoute(input: string): ResolvedRoute` and `AppShellProps`.
- Produces: `resolveRoute('/settings/instances')` with `{ kind: 'redirect', to: '/settings/system' }`; global settings link `href="/settings/system"`.
- [ ] **Step 1: Write failing route and rendering tests**
Add an explicit `resolveRoute` import and assertions to `app-shell.integration.test.tsx`:
```tsx
expect(resolveRoute('/settings/instances')).toMatchObject({
kind: 'redirect',
to: '/settings/system',
});
expect(resolveRoute('/settings/instances/bravo')).toMatchObject({
kind: 'settings-instance-detail',
params: { instanceId: 'bravo' },
});
```
Update the global-workspace test so settings points directly to `/settings/system`, the system settings content renders, and there is no top-level settings sub-navigation:
```tsx
expect(within(navigation).getByRole('link', { name: '设置' })).toHaveAttribute(
'href',
'/settings/system',
);
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
expect(screen.getByRole('heading', { name: '系统与安全' })).toBeTruthy();
expect(screen.queryByRole('navigation', { name: '设置导航' })).toBeNull();
```
Render `/settings/instances` with a spying Fleet source and assert it renders security settings without loading Fleet:
```tsx
const load = vi.fn().mockResolvedValue(snapshot);
render(
<AppShell
pathname="/settings/instances"
fleetDataSource={source(load)}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(screen.getByRole('heading', { name: '系统与安全' })).toBeTruthy();
expect(load).not.toHaveBeenCalled();
```
- [ ] **Step 2: Run the focused test and verify failure**
Run: `corepack pnpm exec vitest run apps/web/src/app-shell.integration.test.tsx`
Expected: FAIL because settings still links to `/settings/instances`, renders the settings sub-navigation, and owns the Fleet-backed instance settings page.
- [ ] **Step 3: Implement the minimal settings route change**
In `resolveRoute`, remove `/settings/instances` from `staticRoutes` and return the redirect before dynamic instance-edit matching:
```tsx
if (pathname === '/settings/instances')
return { kind: 'redirect', pathname, to: '/settings/system' };
```
Change the global item and remove the obsolete page branch and import:
```tsx
{ section: 'settings', href: '/settings/system', label: '设置', icon: 'settings' }
```
Delete the conditional `<nav className="settings-navigation">` block. Remove both `.settings-navigation` rule groups from `styles.css`; they no longer have a consumer.
- [ ] **Step 4: Run the focused test and verify success**
Run: `corepack pnpm exec vitest run apps/web/src/app-shell.integration.test.tsx`
Expected: PASS, including the preserved `/settings/instances/bravo` editor route.
- [ ] **Step 5: Commit the settings slice**
```powershell
git add apps/web/src/app-shell.tsx apps/web/src/app-shell.integration.test.tsx apps/web/src/styles.css
git commit -m "fix: simplify top-level settings"
```
### Task 2: Same-Page Browser Pathname Hook
**Files:**
- Create: `apps/web/src/navigation/use-browser-pathname.ts`
- Create: `apps/web/src/navigation/use-browser-pathname.test.tsx`
- Modify: `apps/web/src/main.tsx`
**Interfaces:**
- Consumes: browser `window.location`, `window.history`, `document` click events, and `popstate`.
- Produces: `useBrowserPathname(): string`.
- [ ] **Step 1: Write failing Hook tests**
Create a jsdom test harness:
```tsx
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useBrowserPathname } from './use-browser-pathname.js';
function Harness() {
const pathname = useBrowserPathname();
return (
<>
<a href="/automation">Automation</a>
<main id="main-content" tabIndex={-1}>{pathname}</main>
</>
);
}
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
vi.restoreAllMocks();
});
```
Cover these exact contracts:
```tsx
it('pushes eligible same-origin links and updates pathname without reloading', () => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
render(<Harness />);
fireEvent.click(screen.getByRole('link', { name: 'Automation' }));
expect(pushState).toHaveBeenCalledWith(null, '', '/automation');
expect(screen.getByRole('main')).toHaveTextContent('/automation');
expect(screen.getByRole('main')).toHaveFocus();
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
});
```
Add a `popstate` test that replaces history with `/fleet`, dispatches `new PopStateEvent('popstate')`, and expects the rendered pathname to update. Add parameterized anchor tests proving no interception for `target="_blank"`, `download`, an external origin, hash-only navigation, `button: 1`, and each of `metaKey`, `ctrlKey`, `shiftKey`, and `altKey`.
- [ ] **Step 2: Run Hook tests and verify failure**
Run: `corepack pnpm exec vitest run apps/web/src/navigation/use-browser-pathname.test.tsx`
Expected: FAIL because `use-browser-pathname.ts` does not exist.
- [ ] **Step 3: Implement the minimal Hook**
Implement a React Hook with this public shape:
```tsx
export function useBrowserPathname(): string
```
The effect must register one document `click` listener and one window `popstate` listener, and clean up both. The click listener must locate `event.target.closest('a[href]')`, reject prevented/non-primary/modified clicks, reject `download` and non-`_self` targets, parse with `new URL(anchor.href, window.location.href)`, require the current origin, reject same-path hash-only changes, then call:
```tsx
event.preventDefault();
window.history.pushState(null, '', `${url.pathname}${url.search}${url.hash}`);
setPathname(window.location.pathname);
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.querySelector<HTMLElement>('#main-content')?.focus({ preventScroll: true });
```
The `popstate` listener updates state from `window.location.pathname`, scrolls, and focuses using the same local helper.
- [ ] **Step 4: Run Hook tests and verify success**
Run: `corepack pnpm exec vitest run apps/web/src/navigation/use-browser-pathname.test.tsx`
Expected: PASS for interception, history traversal, exclusions, focus, and scroll.
- [ ] **Step 5: Keep the AppShell mounted in `main.tsx`**
Add a thin component and pass its reactive pathname to the existing shell:
```tsx
function BrowserApp() {
const pathname = useBrowserPathname();
return (
<ConsoleAuthGate>
<AppShell pathname={pathname} />
</ConsoleAuthGate>
);
}
createRoot(root).render(
<StrictMode>
<BrowserApp />
</StrictMode>,
);
```
This changes only `AppShell` props during navigation; it does not recreate the root or shell component.
- [ ] **Step 6: Run all Web unit tests**
Run: `corepack pnpm --filter @multi-simadmin/web test`
Expected: PASS.
- [ ] **Step 7: Commit the navigation slice**
```powershell
git add apps/web/src/navigation/use-browser-pathname.ts apps/web/src/navigation/use-browser-pathname.test.tsx apps/web/src/main.tsx
git commit -m "feat: navigate workspaces without page reloads"
```
### Task 3: Flush Idle SSE Headers Through The Canary Gateway
**Files:**
- Modify: `apps/api/src/canary-gateway.test.ts`
- Modify: `apps/api/src/canary-gateway.ts`
**Interfaces:**
- Consumes: upstream Node `IncomingMessage` headers and downstream `ServerResponse`.
- Produces: downstream response headers observable before the first upstream body byte.
- [ ] **Step 1: Write the failing idle-stream regression test**
Add a test whose upstream sends and flushes headers but deliberately sends no body:
```tsx
it('flushes SSE response headers before the first upstream event', async () => {
const upstream = createServer((_request, response) => {
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
response.flushHeaders();
});
servers.push(upstream);
const gateway = await startGateway(await fixtureDist(), await listen(upstream));
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 500);
try {
const response = await fetch(`${gateway.origin}/api/v1/events`, {
signal: controller.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
await response.body?.cancel();
} finally {
clearTimeout(timeout);
}
});
```
- [ ] **Step 2: Run the gateway test and verify failure**
Run: `corepack pnpm exec vitest run apps/api/src/canary-gateway.test.ts -t "flushes SSE response headers"`
Expected: FAIL by abort/timeout because the gateway has not committed downstream headers.
- [ ] **Step 3: Flush downstream headers after forwarding upstream headers**
Immediately after the existing `response.writeHead(...)` in `proxyRequest`, add:
```tsx
response.flushHeaders();
```
Keep the existing `pipeline(upstreamResponse, response)` unchanged.
- [ ] **Step 4: Run gateway tests and verify success**
Run: `corepack pnpm exec vitest run apps/api/src/canary-gateway.test.ts`
Expected: PASS for both the idle-header and multi-chunk streaming cases.
- [ ] **Step 5: Commit the gateway slice**
```powershell
git add apps/api/src/canary-gateway.ts apps/api/src/canary-gateway.test.ts
git commit -m "fix: flush proxied event stream headers"
```
### Task 4: Real Browser Navigation And Connection Regression
**Files:**
- Modify: `scripts/real-browser-e2e.mjs`
**Interfaces:**
- Consumes: built Web assets, isolated fixture server or `E2E_ORIGIN`, Chrome DevTools Protocol.
- Produces: end-to-end proof that navigation is reload-free, does not recreate SSE, does not load Fleet from settings, and reaches the connected status.
- [ ] **Step 1: Add instrumentation and failing browser assertions**
Change the fixture event endpoint to return an open SSE response:
```js
if (pathname === '/api/v1/events') {
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
response.flushHeaders();
return;
}
```
Record request pathnames in the isolated server and install a page-lifetime marker before navigating:
```js
await evaluate(cdp, `window.__navigationLifetime = crypto.randomUUID()`);
const lifetime = await evaluate(cdp, `window.__navigationLifetime`);
```
After keyboard navigation from Fleet to settings, assert:
```js
assert.equal(await evaluate(cdp, `window.__navigationLifetime`), lifetime);
assert.equal(await evaluate(cdp, `location.pathname`), '/settings/system');
assert.equal(
await evaluate(cdp, `document.activeElement?.id`),
'main-content',
'Settings navigation must focus the main content',
);
```
Use the recorded request counts before and after navigation to assert that `/api/v1/events` remains at one request and that settings navigation adds no `/api/v1/instances` or `/resources` request. Wait until `.connection-status [data-state="open"]` exists before asserting.
- [ ] **Step 2: Build and run browser E2E to expose remaining failures**
Run: `corepack pnpm run test:e2e:browser`
Expected before all preceding tasks are applied: FAIL on the old settings path, lifetime marker, event request count, or open connection status. Expected after Tasks 1-3: PASS.
- [ ] **Step 3: Update existing E2E route expectations and diagnostics**
Replace both settings navigation expectations from `/settings/instances` and heading `实例` to `/settings/system` and the system/security heading. Update the final PASS line to name same-page navigation and live SSE status. Keep the existing responsive Fleet and Automation assertions unchanged.
- [ ] **Step 4: Re-run browser E2E**
Run: `corepack pnpm run test:e2e:browser`
Expected: PASS in Chrome/Edge with one event subscription, no document reload, no Fleet requests caused by settings, focused main content, and connected stream status.
- [ ] **Step 5: Commit the browser regression**
```powershell
git add scripts/real-browser-e2e.mjs
git commit -m "test: cover persistent browser navigation"
```
### Task 5: Full Verification And Local Runtime
**Files:**
- Verify only; modify a file only if a failing check demonstrates a defect in the scoped implementation.
**Interfaces:**
- Consumes: all prior task outputs.
- Produces: a verified release candidate at `http://127.0.0.1:8789/`.
- [ ] **Step 1: Run unit and integration tests**
Run: `corepack pnpm test`
Expected: PASS.
- [ ] **Step 2: Run static checks**
Run: `corepack pnpm typecheck`
Expected: PASS.
Run: `corepack pnpm lint`
Expected: PASS.
Run: `corepack pnpm format:check`
Expected: PASS.
- [ ] **Step 3: Run the production Web build and real browser suite**
Run: `corepack pnpm --filter @multi-simadmin/web build`
Expected: PASS and emit `apps/web/dist`.
Run: `corepack pnpm run test:e2e:browser`
Expected: PASS.
- [ ] **Step 4: Restart the local production services using the repository's existing runtime commands**
First inspect the current listeners and repository runtime documentation; stop only the exact API/gateway processes belonging to this workspace. Start the API on `127.0.0.1:8790` and the gateway on `127.0.0.1:8789` with the existing configured environment and hidden background windows.
- [ ] **Step 5: Verify the live gateway**
Run:
```powershell
curl.exe -fsS http://127.0.0.1:8789/readyz
curl.exe -sS -N --max-time 2 -D - http://127.0.0.1:8789/api/v1/events
```
Expected: readiness succeeds; the event request prints `HTTP/1.1 200 OK` and `content-type: text/event-stream` before curl times out waiting for an event body.
- [ ] **Step 6: Inspect final Git state**
Run: `git status --short --branch`
Expected: branch contains only the planned commits and is ahead of `origin/main`; do not push.
---
## Self-Review
- Spec coverage: Tasks 1-5 cover security-only settings, legacy redirect semantics, retained instance editing, persistent same-page navigation, link exclusions, history traversal, focus/scroll behavior, idle SSE header delivery, genuine stream state, request-volume regression, and local `8789` verification.
- Placeholder scan: no deferred implementation or unspecified error-handling steps remain.
- Type consistency: `useBrowserPathname(): string`, existing `ResolvedRoute`, and existing `EventStreamClient` contracts are used consistently across tasks.
@@ -0,0 +1,54 @@
# 设置、导航与事件流性能修复设计
日期:2026-07-30
## 目标
消除进入设置页和切换顶部菜单时的明显停顿,让实时连接状态及时进入已连接状态,并把顶层设置收敛为仅包含系统与安全配置。
## 已确认根因
- 顶部“设置”当前进入 `/settings/instances`。该页面先读取实例列表,再为每个实例请求资源数据,造成与安全设置无关的请求放大。
- 顶部导航使用普通链接,`main.tsx` 只读取一次 `window.location.pathname`。每次菜单切换都会整页重载,重新初始化 React、数据源和事件流。
- 控制平面已经刷新 SSE 上游响应头,但 Canary 网关代理写入下游响应头后没有立即刷新。没有事件正文时,浏览器的 `fetch` 一直不能进入 `open` 状态,因此右上角长期显示“正在连接”。
## 路由与设置
- 顶部“设置”链接直接指向 `/settings/system`
- 顶层设置页面只渲染密码保护与 HTTP/HTTPS 安全信息,不再显示设置子导航。
- `/settings/instances` 作为旧地址重定向到 `/settings/system`,避免旧书签落入不存在页面。
- `/settings/instances/:instanceId` 继续保留实例编辑器,只从节点详情的“编辑实例”入口访问。
- 不删除实例编辑组件、实例创建流程或相关 API。
## 同页导航
- 新增一个小型浏览器路径 Hook,维护当前 `pathname`
- 拦截无修饰键、同源、当前窗口的站内链接:调用 `history.pushState` 并更新路径,不触发整页重载。
- 外链、下载链接、`target` 链接、不同源链接和带修饰键点击继续使用浏览器默认行为。
- 监听 `popstate`,保证浏览器前进和后退可用。
- 页面切换后将主内容聚焦并恢复到页面顶部,保持键盘与阅读器导航可预期。
- `AppShell` 在菜单切换期间保持挂载,因此默认数据源和事件流客户端不会重建。
## 事件流
- Canary 网关收到上游响应头后,在写入下游响应头时立即调用 `flushHeaders()`
- 继续以流方式转发正文,不缓存 SSE,不修改认证头、游标或重连策略。
- 没有任何事件时,浏览器仍能立即收到 `200``text/event-stream`,右上角切换为“实时连接正常”。
- 真正断线时仍显示“正在重新连接”,不通过隐藏状态标签掩盖故障。
## 测试
- 路由测试验证 `/settings/instances` 重定向到 `/settings/system`,实例编辑路由不变。
- AppShell 测试验证顶栏设置链接指向安全设置,且设置子导航不再出现。
- 浏览器路径 Hook 测试验证站内导航、前进后退和不应拦截的链接。
- Canary 网关测试构造“只刷新响应头、不发送事件正文”的 SSE 上游,验证下游请求在正文到达前即可获得响应。
- 集成或浏览器测试验证菜单切换不造成页面重载、事件订阅不重建,设置页不请求 Fleet 数据。
- 最终在本地 `8789` 验证设置页响应、菜单切换和实时连接状态。
## 非目标
- 不引入第三方路由库。
- 不删除实例编辑路由或实例创建功能。
- 不改变事件内容、事件游标、重连退避或认证模型。
- 不通过轮询替代 SSE。
- 不重构节点、自动化或安全设置页面的视觉设计。
+69 -5
View File
@@ -19,6 +19,7 @@ const FORBIDDEN_PORT = 8788;
const EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN);
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR?.trim();
const FLEET_FIXTURE = process.env.E2E_FLEET_FIXTURE !== '0';
const requestPathnames = [];
const CHROME_CANDIDATES = [
process.env.CHROME_BIN,
@@ -77,11 +78,20 @@ const FLEET_FIXTURE_ITEMS = [
async function builtAssetHandler(request, response) {
const pathname = new URL(request.url ?? '/', 'http://e2e.local').pathname;
if (pathname === '/favicon.ico' || pathname === '/api/v1/events') {
requestPathnames.push(pathname);
if (pathname === '/favicon.ico') {
response.statusCode = 204;
response.end();
return;
}
if (pathname === '/api/v1/events') {
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
response.flushHeaders();
return;
}
let body;
if (pathname === '/api/v1/auth/status')
body = { configured: false, protectionEnabled: false, authenticated: false };
@@ -737,7 +747,7 @@ try {
`${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`,
);
assert.equal(
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
layout.hardwareEntries.every((entry) => entry.labelFits && entry.valueFits),
true,
`${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`,
@@ -884,7 +894,51 @@ try {
);
await assertWidePageGutters(cdp, 'Nodes');
await keyboardNavigate(cdp, '设置', '/settings/instances', '实例');
await eventually(
cdp,
`document.querySelector('.connection-status [data-state="open"]') !== null`,
'Event stream did not reach the open state',
);
await evaluate(cdp, `window.__navigationLifetime = crypto.randomUUID()`);
const navigationLifetime = await evaluate(cdp, `window.__navigationLifetime`);
const requestCountsBeforeSettings = EXTERNAL_ORIGIN
? undefined
: {
events: requestPathnames.filter((pathname) => pathname === '/api/v1/events').length,
fleet: requestPathnames.filter(
(pathname) =>
pathname === '/api/v1/instances' ||
/^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname),
).length,
};
await keyboardNavigate(cdp, '设置', '/settings/system', '密码保护');
assert.equal(
await evaluate(cdp, `window.__navigationLifetime`),
navigationLifetime,
'Settings navigation reloaded the document',
);
assert.equal(
await evaluate(cdp, `document.activeElement?.id`),
'main-content',
'Settings navigation must focus the main content',
);
if (requestCountsBeforeSettings) {
await delay(100);
assert.equal(
requestPathnames.filter((pathname) => pathname === '/api/v1/events').length,
requestCountsBeforeSettings.events,
'Settings navigation recreated the event subscription',
);
assert.equal(
requestPathnames.filter(
(pathname) =>
pathname === '/api/v1/instances' ||
/^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname),
).length,
requestCountsBeforeSettings.fleet,
'Settings navigation requested Fleet data',
);
}
await assertWidePageGutters(cdp, 'Settings');
await keyboardNavigate(cdp, '自动化', '/automation', '自动化');
await assertWidePageGutters(cdp, 'Automation');
@@ -980,7 +1034,17 @@ try {
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
'/audit alias did not select operation audit',
);
await keyboardNavigate(cdp, '设置', '/settings/instances', '实例');
await navigate(cdp, `${origin}/settings/instances`);
await eventually(
cdp,
`location.pathname === '/settings/system'`,
'Legacy settings route did not replace the browser pathname',
);
await eventually(
cdp,
`document.querySelector('h1')?.textContent.trim() === '密码保护'`,
'Legacy settings route did not render password protection',
);
assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`);
console.log(`PASS real Chrome E2E (${chromeBinary})`);
@@ -990,7 +1054,7 @@ try {
: `PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`,
);
console.log(
'PASS three-item navigation, Automation drawer and aliases, batch selection, and 390/768/1024/1440/1920 responsive layouts',
'PASS persistent navigation, live SSE status, Automation workflows, and 390/768/1024/1440/1920 responsive layouts',
);
} catch (error) {
console.error(error instanceof Error ? error.stack : error);