Compare commits
11
Commits
570edf6bb2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc0f27f12 | ||
|
|
95abd09c39 | ||
|
|
4fbc902325 | ||
|
|
25502a52bb | ||
|
|
144e17c220 | ||
|
|
3a6652be0f | ||
|
|
af5abb4b82 | ||
|
|
3bbd227c58 | ||
|
|
d60cff1dd8 | ||
|
|
6ec8e4f6a6 | ||
|
|
cf2cf6827d |
@@ -334,6 +334,31 @@ describe('same-origin canary gateway', () => {
|
|||||||
await reader?.cancel();
|
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 () => {
|
it('has reversible, idempotent start/stop lifecycle', async () => {
|
||||||
const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 });
|
const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 });
|
||||||
gateways.push(gateway);
|
gateways.push(gateway);
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ async function proxyRequest(
|
|||||||
upstreamResponse.statusMessage,
|
upstreamResponse.statusMessage,
|
||||||
withoutHopByHop(upstreamResponse.headers),
|
withoutHopByHop(upstreamResponse.headers),
|
||||||
);
|
);
|
||||||
|
response.flushHeaders();
|
||||||
pipeline(upstreamResponse, response)
|
pipeline(upstreamResponse, response)
|
||||||
.then(resolvePromise)
|
.then(resolvePromise)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
+3
-6
@@ -4,12 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<meta name="theme-color" content="#142137" />
|
<meta name="theme-color" content="#f3f0e7" />
|
||||||
<link
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
rel="icon"
|
<title>SimAdmin Control · 多节点控制台</title>
|
||||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23315bea%22/><path d=%22M18 42h6V31h-6zm11 0h6V22h-6zm11 0h6V13h-6z%22 fill=%22white%22/></svg>"
|
|
||||||
/>
|
|
||||||
<title>SimAdmin Nexus · 多节点控制台</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="#f6c95f" stroke="#725d42" stroke-width="3" />
|
||||||
|
<rect x="15" y="35" width="8" height="13" rx="3" fill="#725d42" />
|
||||||
|
<rect x="28" y="26" width="8" height="22" rx="3" fill="#725d42" />
|
||||||
|
<rect x="41" y="16" width="8" height="32" rx="3" fill="#725d42" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('browser branding', () => {
|
||||||
|
it('uses the SimAdmin Control title and warm static favicon', async () => {
|
||||||
|
const indexHtml = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
expect(indexHtml).toContain('href="/favicon.svg"');
|
||||||
|
expect(indexHtml).toContain('content="#f3f0e7"');
|
||||||
|
expect(indexHtml).toContain('<title>SimAdmin Control · 多节点控制台</title>');
|
||||||
|
|
||||||
|
const faviconSvg = await readFile(new URL('../public/favicon.svg', import.meta.url), 'utf8');
|
||||||
|
expect(faviconSvg).toContain('#f6c95f');
|
||||||
|
expect(faviconSvg).toContain('#725d42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 { AuditDataSource } from './audit/audit-page.js';
|
||||||
import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js';
|
import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js';
|
||||||
import type { EventStreamClient } from './events/event-stream-client.js';
|
import type { EventStreamClient } from './events/event-stream-client.js';
|
||||||
@@ -110,6 +110,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.getByLabelText('控制台版本 0.1.0')).toBeTruthy();
|
expect(screen.getByLabelText('控制台版本 0.1.0')).toBeTruthy();
|
||||||
expect(screen.getByText('v0.1.0')).toBeTruthy();
|
expect(screen.getByText('v0.1.0')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('多节点蜂窝设备控制中心')).toBeNull();
|
||||||
|
expect(screen.getByRole('link', { name: 'animal-island-ui' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: 'CC BY-NC 4.0' })).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
consoleError.mock.calls.some((call) =>
|
consoleError.mock.calls.some((call) =>
|
||||||
call.some((argument) => String(argument).includes('unique "key" prop')),
|
call.some((argument) => String(argument).includes('unique "key" prop')),
|
||||||
@@ -157,9 +160,11 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(card).getByText('18.4%')).toBeTruthy();
|
expect(within(card).getByText('18.4%')).toBeTruthy();
|
||||||
expect(within(card).getByText('63.2%')).toBeTruthy();
|
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||||
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
expect(within(card).getByText('138 •••• 8000')).toBeTruthy();
|
||||||
expect(await within(card).findByText('收到')).toBeTruthy();
|
expect(await within(card).findByText('收到')).toBeTruthy();
|
||||||
expect(within(card).getByText('13900139000')).toBeTruthy();
|
expect(within(card).getByText('139 •••• 9000')).toBeTruthy();
|
||||||
|
expect(within(card).queryByText('13800138000')).toBeNull();
|
||||||
|
expect(within(card).queryByText('13900139000')).toBeNull();
|
||||||
expect(within(card).queryByText(/这是一条用于聚合页展示/)).toBeNull();
|
expect(within(card).queryByText(/这是一条用于聚合页展示/)).toBeNull();
|
||||||
expect(within(card).getByText(/2026/)).toBeTruthy();
|
expect(within(card).getByText(/2026/)).toBeTruthy();
|
||||||
expect(within(card).queryByText('能力未知')).toBeNull();
|
expect(within(card).queryByText('能力未知')).toBeNull();
|
||||||
@@ -174,6 +179,10 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(card).queryByRole('button', { name: '重启服务 Bravo' })).toBeNull();
|
expect(within(card).queryByRole('button', { name: '重启服务 Bravo' })).toBeNull();
|
||||||
expect(within(card).queryByRole('button', { name: '系统重启 Bravo' })).toBeNull();
|
expect(within(card).queryByRole('button', { name: '系统重启 Bravo' })).toBeNull();
|
||||||
|
|
||||||
|
await user.click(within(card).getByRole('button', { name: '显示 Bravo 手机号' }));
|
||||||
|
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||||
|
|
||||||
await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' }));
|
await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' }));
|
||||||
|
|
||||||
expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
|
expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
|
||||||
@@ -194,7 +203,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(bravo).getByText('需要认证')).toBeTruthy();
|
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 jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||||
const scheduleDataSource: ScheduleDataSource = {
|
const scheduleDataSource: ScheduleDataSource = {
|
||||||
listSchedules: vi.fn().mockResolvedValue([]),
|
listSchedules: vi.fn().mockResolvedValue([]),
|
||||||
@@ -225,7 +234,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
'/automation',
|
'/automation',
|
||||||
);
|
);
|
||||||
expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
|
expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
|
||||||
'/settings/instances',
|
'/settings/system',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'),
|
within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'),
|
||||||
@@ -245,13 +254,31 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
|
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
|
||||||
|
|
||||||
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
|
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
|
||||||
const settingsNavigation = screen.getByRole('navigation', { name: '设置导航' });
|
expect(screen.getByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||||
expect(within(settingsNavigation).getByRole('link', { name: '实例管理' })).toBeTruthy();
|
expect(screen.queryByRole('navigation', { name: '设置导航' })).toBeNull();
|
||||||
expect(
|
});
|
||||||
within(settingsNavigation)
|
|
||||||
.getByRole('link', { name: '系统与安全' })
|
it('redirects legacy top-level instance settings without loading Fleet', () => {
|
||||||
.getAttribute('aria-current'),
|
expect(resolveRoute('/settings/instances')).toMatchObject({
|
||||||
).toBe('page');
|
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', () => {
|
it('hides placeholder dev version badge in the top bar', () => {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
type NotificationsDataSource,
|
type NotificationsDataSource,
|
||||||
} from './instances/notifications-module.js';
|
} from './instances/notifications-module.js';
|
||||||
import { OtaModule, type OtaDataSource } from './instances/ota-module.js';
|
import { OtaModule, type OtaDataSource } from './instances/ota-module.js';
|
||||||
import { InstanceSettingsPage } from './settings/instance-settings-page.js';
|
|
||||||
import {
|
import {
|
||||||
InstanceDetail,
|
InstanceDetail,
|
||||||
INSTANCE_MODULE_LABELS,
|
INSTANCE_MODULE_LABELS,
|
||||||
@@ -69,7 +68,6 @@ export type RouteKind =
|
|||||||
| 'job-detail'
|
| 'job-detail'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'audit-detail'
|
| 'audit-detail'
|
||||||
| 'settings-instances'
|
|
||||||
| 'settings-instance-detail'
|
| 'settings-instance-detail'
|
||||||
| 'settings-system'
|
| 'settings-system'
|
||||||
| 'not-found';
|
| 'not-found';
|
||||||
@@ -139,13 +137,14 @@ function decode(value: string): string {
|
|||||||
export function resolveRoute(input: string): ResolvedRoute {
|
export function resolveRoute(input: string): ResolvedRoute {
|
||||||
const pathname = normalize(input);
|
const pathname = normalize(input);
|
||||||
if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' };
|
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>> = {
|
const staticRoutes: Readonly<Record<string, RouteKind>> = {
|
||||||
'/fleet': 'fleet',
|
'/fleet': 'fleet',
|
||||||
'/automation': 'automation',
|
'/automation': 'automation',
|
||||||
'/instances/new': 'instance-new',
|
'/instances/new': 'instance-new',
|
||||||
'/jobs': 'jobs',
|
'/jobs': 'jobs',
|
||||||
'/audit': 'audit',
|
'/audit': 'audit',
|
||||||
'/settings/instances': 'settings-instances',
|
|
||||||
'/settings/system': 'settings-system',
|
'/settings/system': 'settings-system',
|
||||||
};
|
};
|
||||||
if (staticRoutes[pathname]) return { kind: staticRoutes[pathname], pathname };
|
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 === 'settings-system') return <ConsoleAuthSettings />;
|
||||||
if (route.kind === 'not-found')
|
if (route.kind === 'not-found')
|
||||||
return (
|
return (
|
||||||
@@ -405,7 +396,6 @@ function Page({
|
|||||||
'job-detail': '任务详情',
|
'job-detail': '任务详情',
|
||||||
audit: '审计',
|
audit: '审计',
|
||||||
'audit-detail': '审计事件',
|
'audit-detail': '审计事件',
|
||||||
'settings-instances': '实例设置',
|
|
||||||
'settings-instance-detail': '实例设置',
|
'settings-instance-detail': '实例设置',
|
||||||
'settings-system': '系统设置',
|
'settings-system': '系统设置',
|
||||||
};
|
};
|
||||||
@@ -432,7 +422,7 @@ const GLOBAL_NAVIGATION: readonly {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
|
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
|
||||||
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
|
{ 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 = {
|
const STREAM_LABELS = {
|
||||||
@@ -659,7 +649,6 @@ export function AppShell({
|
|||||||
</span>
|
</span>
|
||||||
<span className="product-copy">
|
<span className="product-copy">
|
||||||
<strong>SimAdmin Control</strong>
|
<strong>SimAdmin Control</strong>
|
||||||
<small>多节点蜂窝设备控制中心</small>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav className="global-navigation" aria-label="全局导航">
|
<nav className="global-navigation" aria-label="全局导航">
|
||||||
@@ -694,22 +683,6 @@ export function AppShell({
|
|||||||
</header>
|
</header>
|
||||||
<div className="app-layout app-layout-single">
|
<div className="app-layout app-layout-single">
|
||||||
<main id="main-content" tabIndex={-1}>
|
<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 ? (
|
{routeInstanceId && instanceLoading ? (
|
||||||
<p role="status" aria-label="实例加载状态">
|
<p role="status" aria-label="实例加载状态">
|
||||||
正在加载实例…
|
正在加载实例…
|
||||||
@@ -748,9 +721,9 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
<footer className="app-footer" aria-label="项目与组件库信息">
|
<footer className="app-footer" aria-label="项目与组件库信息">
|
||||||
<p>
|
<p>
|
||||||
SimAdmin 聚合控制台 · 界面组件来自{' '}
|
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
||||||
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
<span aria-hidden="true"> · </span>
|
||||||
(CC BY-NC 4.0,仅限非商业使用)
|
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,13 +70,10 @@ export function ConsoleAuthSettings({
|
|||||||
return (
|
return (
|
||||||
<section className="settings-page" aria-labelledby="password-protection-title">
|
<section className="settings-page" aria-labelledby="password-protection-title">
|
||||||
<header className="page-heading">
|
<header className="page-heading">
|
||||||
<p className="eyebrow">SYSTEM SETTINGS</p>
|
|
||||||
<h1 id="password-protection-title">
|
<h1 id="password-protection-title">
|
||||||
<Title color="app-green">密码保护</Title>
|
<Title color="app-green">密码保护</Title>
|
||||||
</h1>
|
</h1>
|
||||||
<p>可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。</p>
|
|
||||||
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||||
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
|
||||||
</header>
|
</header>
|
||||||
{error && !status ? <p role="alert">{error}</p> : null}
|
{error && !status ? <p role="alert">{error}</p> : null}
|
||||||
{status ? (
|
{status ? (
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
|
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
|
||||||
|
expect(screen.queryByText('MULTI SIMADMIN')).toBeNull();
|
||||||
|
expect(screen.queryByText(/密码保护已启用。验证通过后/u)).toBeNull();
|
||||||
expect(screen.queryByText('secret fleet')).toBeNull();
|
expect(screen.queryByText('secret fleet')).toBeNull();
|
||||||
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
|
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
|
||||||
await user.click(screen.getByRole('button', { name: '进入管理台' }));
|
await user.click(screen.getByRole('button', { name: '进入管理台' }));
|
||||||
@@ -79,8 +81,11 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||||
|
expect(screen.queryByText('SYSTEM SETTINGS')).toBeNull();
|
||||||
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull();
|
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull();
|
||||||
expect(screen.getByText(/可在当前管理台直接完成首次密码设置/)).toBeTruthy();
|
expect(screen.queryByText(/可在当前管理台直接完成首次密码设置/)).toBeNull();
|
||||||
|
expect(screen.queryByText(/参考单实例 SimAdmin/u)).toBeNull();
|
||||||
|
expect(screen.getByText(/当前部署为 HTTP/u)).toBeTruthy();
|
||||||
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
||||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
|
|||||||
return (
|
return (
|
||||||
<main className="auth-screen">
|
<main className="auth-screen">
|
||||||
<section className="auth-card" aria-labelledby="console-login-title">
|
<section className="auth-card" aria-labelledby="console-login-title">
|
||||||
<p className="eyebrow">MULTI SIMADMIN</p>
|
|
||||||
<h1 id="console-login-title">请输入访问密码</h1>
|
<h1 id="console-login-title">请输入访问密码</h1>
|
||||||
<p>密码保护已启用。验证通过后才能进入聚合工作台。</p>
|
|
||||||
<form onSubmit={(event) => void submit(event)}>
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
<label htmlFor="console-password">访问密码</label>
|
<label htmlFor="console-password">访问密码</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe('AutomationPage', () => {
|
|||||||
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
|
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
|
||||||
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
|
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
|
||||||
expect(await screen.findByText('暂无计划任务')).not.toBeNull();
|
expect(await screen.findByText('暂无计划任务')).not.toBeNull();
|
||||||
|
expect(screen.queryByText(/按北京时间(UTC\+8)统一调度/u)).toBeNull();
|
||||||
|
expect(screen.queryByText('创建定时重启、系统重启或短信发送任务。')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens a progressive editor with dynamic tags and no timezone selector', async () => {
|
it('opens a progressive editor with dynamic tags and no timezone selector', async () => {
|
||||||
@@ -55,6 +57,7 @@ describe('AutomationPage', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
||||||
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
||||||
expect(dialog).not.toBeNull();
|
expect(dialog).not.toBeNull();
|
||||||
|
expect(within(dialog).queryByText('新建自动化')).toBeNull();
|
||||||
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
|
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
|
||||||
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
|
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
|
||||||
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
|
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
|
||||||
|
|||||||
@@ -343,7 +343,6 @@ export function AutomationPage({
|
|||||||
<header className="workbench-heading">
|
<header className="workbench-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="automation-title">自动化</h1>
|
<h1 id="automation-title">自动化</h1>
|
||||||
<p>按北京时间(UTC+8)统一调度多实例运维操作。</p>
|
|
||||||
</div>
|
</div>
|
||||||
{tab === 'schedules' ? (
|
{tab === 'schedules' ? (
|
||||||
<button type="button" className="primary-action" onClick={createTask}>
|
<button type="button" className="primary-action" onClick={createTask}>
|
||||||
@@ -377,7 +376,6 @@ export function AutomationPage({
|
|||||||
{!loading && tasks.length === 0 ? (
|
{!loading && tasks.length === 0 ? (
|
||||||
<div className="automation-empty">
|
<div className="automation-empty">
|
||||||
<strong>暂无计划任务</strong>
|
<strong>暂无计划任务</strong>
|
||||||
<span>创建定时重启、系统重启或短信发送任务。</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{tasks.length > 0 ? (
|
{tasks.length > 0 ? (
|
||||||
@@ -512,7 +510,6 @@ export function AutomationPage({
|
|||||||
(runs.length === 0 ? (
|
(runs.length === 0 ? (
|
||||||
<div className="automation-empty">
|
<div className="automation-empty">
|
||||||
<strong>暂无执行记录</strong>
|
<strong>暂无执行记录</strong>
|
||||||
<span>任务执行后,结果会显示在这里。</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="schedule-table-wrap">
|
<div className="schedule-table-wrap">
|
||||||
@@ -547,7 +544,6 @@ export function AutomationPage({
|
|||||||
{recordsContent ?? (
|
{recordsContent ?? (
|
||||||
<div className="automation-empty">
|
<div className="automation-empty">
|
||||||
<strong>暂无操作审计</strong>
|
<strong>暂无操作审计</strong>
|
||||||
<span>安全追踪与故障排查记录会显示在这里。</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +567,6 @@ export function AutomationPage({
|
|||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span className="drawer-eyebrow">{editingTask ? '任务设置' : '新建自动化'}</span>
|
|
||||||
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
|
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ const snapshot: FleetSnapshot = {
|
|||||||
summary: {
|
summary: {
|
||||||
version: '1.1.6',
|
version: '1.1.6',
|
||||||
freshness: 'fresh',
|
freshness: 'fresh',
|
||||||
resources: { cpuPercent: 24, memoryPercent: 51, maxTemperatureCelsius: 42 },
|
resources: {
|
||||||
|
cpuPercent: 24,
|
||||||
|
memoryPercent: 51,
|
||||||
|
maxTemperatureCelsius: 42,
|
||||||
|
phoneNumbers: ['+852 5550 0100'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -33,6 +38,95 @@ const snapshot: FleetSnapshot = {
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('FleetPage card navigation', () => {
|
describe('FleetPage card navigation', () => {
|
||||||
|
it('masks both phone locations and reveals them only for the selected card', async () => {
|
||||||
|
const privacySnapshot: FleetSnapshot = {
|
||||||
|
instances: [
|
||||||
|
...snapshot.instances,
|
||||||
|
{
|
||||||
|
id: 'beta',
|
||||||
|
name: 'Beta modem',
|
||||||
|
url: 'http://192.168.1.3',
|
||||||
|
tags: [],
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
statuses: new Map([
|
||||||
|
...snapshot.statuses,
|
||||||
|
[
|
||||||
|
'beta',
|
||||||
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
summary: {
|
||||||
|
version: '1.2.0',
|
||||||
|
freshness: 'fresh' as const,
|
||||||
|
resources: { phoneNumbers: ['13812345678'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const messagesDataSource = {
|
||||||
|
load: vi.fn(async (instanceId: string) =>
|
||||||
|
instanceId === 'alpha'
|
||||||
|
? {
|
||||||
|
latest: {
|
||||||
|
id: 'sms-1',
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: '13900139000',
|
||||||
|
content: 'status',
|
||||||
|
timestamp: '2026-07-19T08:30:00.000Z',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FleetPage initialData={privacySnapshot} messagesDataSource={messagesDataSource} />);
|
||||||
|
|
||||||
|
const alpha = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||||
|
const beta = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
||||||
|
expect(within(alpha).getByText('+852 •••• 0100')).toBeTruthy();
|
||||||
|
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
||||||
|
expect(await within(alpha).findByText('139 •••• 9000')).toBeTruthy();
|
||||||
|
expect(within(alpha).queryByText('+852 5550 0100')).toBeNull();
|
||||||
|
expect(within(alpha).queryByText('13900139000')).toBeNull();
|
||||||
|
|
||||||
|
const reveal = within(alpha).getByRole('button', { name: '显示 Alpha modem 手机号' });
|
||||||
|
expect(reveal.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
fireEvent.click(reveal);
|
||||||
|
|
||||||
|
expect(within(alpha).getByText('+852 5550 0100')).toBeTruthy();
|
||||||
|
expect(within(alpha).getByText('13900139000')).toBeTruthy();
|
||||||
|
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
||||||
|
expect(within(beta).queryByText('13812345678')).toBeNull();
|
||||||
|
expect(within(alpha).getByRole('button', { name: '隐藏 Alpha modem 手机号' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show a privacy toggle when neither phone location has a number', () => {
|
||||||
|
const noNumbers: FleetSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
statuses: new Map([
|
||||||
|
[
|
||||||
|
'alpha',
|
||||||
|
{
|
||||||
|
...snapshot.statuses.get('alpha')!,
|
||||||
|
summary: {
|
||||||
|
...snapshot.statuses.get('alpha')!.summary,
|
||||||
|
resources: { cpuPercent: 24, memoryPercent: 51 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FleetPage initialData={noNumbers} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||||
|
expect(within(card).getByText('暂未获取')).toBeTruthy();
|
||||||
|
expect(within(card).queryByRole('button', { name: /手机号/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
|
it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
|
||||||
render(<FleetPage initialData={snapshot} />);
|
render(<FleetPage initialData={snapshot} />);
|
||||||
|
|
||||||
@@ -47,6 +141,11 @@ describe('FleetPage card navigation', () => {
|
|||||||
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||||
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||||
);
|
);
|
||||||
|
expect(screen.queryByText('NODE DIRECTORY')).toBeNull();
|
||||||
|
expect(screen.queryByText(/LIVE NODE MATRIX/u)).toBeNull();
|
||||||
|
expect(screen.queryByText('已纳入统一管理')).toBeNull();
|
||||||
|
expect(screen.queryByText('连接与认证正常')).toBeNull();
|
||||||
|
expect(screen.queryByText('离线、认证或未知')).toBeNull();
|
||||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||||
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
@@ -147,11 +246,12 @@ describe('FleetPage search and filter toolbar', () => {
|
|||||||
|
|
||||||
const search = screen.getByRole('search', { name: '实例搜索与筛选' });
|
const search = screen.getByRole('search', { name: '实例搜索与筛选' });
|
||||||
expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy();
|
expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy();
|
||||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
expect(within(search).queryByText(/显示/)).toBeNull();
|
||||||
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||||
|
|
||||||
fireEvent.click(within(search).getByRole('button', { name: /离线/ }));
|
fireEvent.click(within(search).getByRole('button', { name: /离线/ }));
|
||||||
expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull();
|
expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull();
|
||||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*0\s*\/\s*1/);
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*0\s*\/\s*1/);
|
||||||
expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy();
|
expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), {
|
fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), {
|
||||||
@@ -161,7 +261,7 @@ describe('FleetPage search and filter toolbar', () => {
|
|||||||
|
|
||||||
fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' }));
|
fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' }));
|
||||||
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
||||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
|
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
FleetMessagesDataSource,
|
FleetMessagesDataSource,
|
||||||
} from './fleet-messages-api-data-source.js';
|
} from './fleet-messages-api-data-source.js';
|
||||||
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||||||
|
import { formatPhoneNumbers } from './phone-privacy.js';
|
||||||
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
|
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
|
||||||
import { safeUiError } from '../ui/locale.js';
|
import { safeUiError } from '../ui/locale.js';
|
||||||
import { Icon, type IconName } from '../ui/icon.js';
|
import { Icon, type IconName } from '../ui/icon.js';
|
||||||
@@ -171,6 +172,7 @@ export function FleetPage({
|
|||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
});
|
});
|
||||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
|
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
|
||||||
const [batchBusy, setBatchBusy] = useState(false);
|
const [batchBusy, setBatchBusy] = useState(false);
|
||||||
@@ -458,6 +460,15 @@ export function FleetPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePhonePrivacy(id: string): void {
|
||||||
|
setRevealedPhoneIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
|
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
|
||||||
const instances = snapshot?.instances ?? [];
|
const instances = snapshot?.instances ?? [];
|
||||||
return model.selectedIds.flatMap((id) => {
|
return model.selectedIds.flatMap((id) => {
|
||||||
@@ -637,7 +648,6 @@ export function FleetPage({
|
|||||||
<div className="fleet-heading">
|
<div className="fleet-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="fleet-title">节点</h1>
|
<h1 id="fleet-title">节点</h1>
|
||||||
<p>统一观察蜂窝设备健康度、连接状态与关键资源,快速进入节点执行运维。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="fleet-actions">
|
<div className="fleet-actions">
|
||||||
<a className="primary-action" href="/instances/new">
|
<a className="primary-action" href="/instances/new">
|
||||||
@@ -668,7 +678,6 @@ export function FleetPage({
|
|||||||
>
|
>
|
||||||
<div className="fleet-sidebar-header">
|
<div className="fleet-sidebar-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="fleet-sidebar-eyebrow">NODE DIRECTORY</p>
|
|
||||||
<strong>
|
<strong>
|
||||||
<Icon name="server" />
|
<Icon name="server" />
|
||||||
节点总览
|
节点总览
|
||||||
@@ -701,7 +710,6 @@ export function FleetPage({
|
|||||||
实例总数
|
实例总数
|
||||||
</span>
|
</span>
|
||||||
<strong>{fleetSummary.total}</strong>
|
<strong>{fleetSummary.total}</strong>
|
||||||
<small>已纳入统一管理</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div data-tone="online">
|
<div data-tone="online">
|
||||||
<span>
|
<span>
|
||||||
@@ -709,7 +717,6 @@ export function FleetPage({
|
|||||||
在线
|
在线
|
||||||
</span>
|
</span>
|
||||||
<strong>{fleetSummary.online}</strong>
|
<strong>{fleetSummary.online}</strong>
|
||||||
<small>连接与认证正常</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div data-tone="attention">
|
<div data-tone="attention">
|
||||||
<span>
|
<span>
|
||||||
@@ -717,7 +724,6 @@ export function FleetPage({
|
|||||||
需处理
|
需处理
|
||||||
</span>
|
</span>
|
||||||
<strong>{fleetSummary.attention}</strong>
|
<strong>{fleetSummary.attention}</strong>
|
||||||
<small>离线、认证或未知</small>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -796,9 +802,6 @@ export function FleetPage({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="fleet-sidebar-result-count" aria-live="polite">
|
|
||||||
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
|
||||||
</p>
|
|
||||||
{activeFilterChips.length > 0 ? (
|
{activeFilterChips.length > 0 ? (
|
||||||
<div className="fleet-active-filters" aria-label="当前筛选条件">
|
<div className="fleet-active-filters" aria-label="当前筛选条件">
|
||||||
{activeFilterChips.map((chip) => (
|
{activeFilterChips.map((chip) => (
|
||||||
@@ -885,9 +888,6 @@ export function FleetPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="fleet-results-header">
|
<div className="fleet-results-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="fleet-results-eyebrow">
|
|
||||||
<Icon name="grid" /> LIVE NODE MATRIX
|
|
||||||
</p>
|
|
||||||
<p className="fleet-result-count" aria-live="polite">
|
<p className="fleet-result-count" aria-live="polite">
|
||||||
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
||||||
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}
|
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}
|
||||||
@@ -969,7 +969,12 @@ export function FleetPage({
|
|||||||
<>
|
<>
|
||||||
<section className="fleet-card-section" aria-label="实例卡片">
|
<section className="fleet-card-section" aria-label="实例卡片">
|
||||||
<div className="fleet-card-grid">
|
<div className="fleet-card-grid">
|
||||||
{model.rows.map((row) => (
|
{model.rows.map((row) => {
|
||||||
|
const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
|
||||||
|
const latestMessage = messageStates.get(row.id)?.latest;
|
||||||
|
const phoneNumbersRevealed = revealedPhoneIds.has(row.id);
|
||||||
|
const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage);
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
role="article"
|
role="article"
|
||||||
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
|
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
|
||||||
@@ -1015,7 +1020,9 @@ export function FleetPage({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`选择 ${row.displayName}`}
|
aria-label={`选择 ${row.displayName}`}
|
||||||
checked={row.selected}
|
checked={row.selected}
|
||||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
onChange={(event) =>
|
||||||
|
toggleOne(row.id, event.currentTarget.checked)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1151,13 +1158,29 @@ export function FleetPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
|
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
|
||||||
<div>
|
<div className="fleet-card-phone">
|
||||||
<dt>
|
<dt>
|
||||||
<Icon name="phone" />
|
<Icon name="phone" />
|
||||||
手机号
|
手机号
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
|
<span className="fleet-card-phone-value">
|
||||||
|
{phoneNumbers.length > 0
|
||||||
|
? formatPhoneNumbers(phoneNumbers, phoneNumbersRevealed)
|
||||||
|
: '暂未获取'}
|
||||||
|
</span>
|
||||||
|
{hasPrivatePhone ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fleet-phone-privacy-toggle"
|
||||||
|
aria-label={`${phoneNumbersRevealed ? '隐藏' : '显示'} ${row.displayName} 手机号`}
|
||||||
|
aria-pressed={phoneNumbersRevealed}
|
||||||
|
title={phoneNumbersRevealed ? '隐藏手机号' : '显示手机号'}
|
||||||
|
onClick={() => togglePhonePrivacy(row.id)}
|
||||||
|
>
|
||||||
|
<Icon name={phoneNumbersRevealed ? 'eye-off' : 'eye'} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1207,7 +1230,9 @@ export function FleetPage({
|
|||||||
duration={0}
|
duration={0}
|
||||||
aria-label="内存使用率"
|
aria-label="内存使用率"
|
||||||
/>
|
/>
|
||||||
<strong>{percent(row.status.summary.resources.memoryPercent)}</strong>
|
<strong>
|
||||||
|
{percent(row.status.summary.resources.memoryPercent)}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1229,7 +1254,12 @@ export function FleetPage({
|
|||||||
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
|
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>{messageStates.get(row.id)!.latest!.phoneNumber}</span>
|
<span>
|
||||||
|
{formatPhoneNumbers(
|
||||||
|
[messageStates.get(row.id)!.latest!.phoneNumber],
|
||||||
|
phoneNumbersRevealed,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<time
|
<time
|
||||||
className="fleet-card-footer-time"
|
className="fleet-card-footer-time"
|
||||||
@@ -1259,7 +1289,8 @@ export function FleetPage({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav className="pagination" aria-label="实例分页">
|
<nav className="pagination" aria-label="实例分页">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formatPhoneNumbers, maskPhoneNumber } from './phone-privacy.js';
|
||||||
|
|
||||||
|
describe('phone privacy formatting', () => {
|
||||||
|
it('preserves an international prefix and the last four digits', () => {
|
||||||
|
expect(maskPhoneNumber('+852 5550 0100')).toBe('+852 •••• 0100');
|
||||||
|
expect(maskPhoneNumber('+852-5550-0100')).toBe('+852 •••• 0100');
|
||||||
|
expect(maskPhoneNumber('+85255500100')).toBe('+852 •••• 0100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the first three and last four digits for local numbers', () => {
|
||||||
|
expect(maskPhoneNumber('13812345678')).toBe('138 •••• 5678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats multiple numbers without changing revealed values', () => {
|
||||||
|
const numbers = ['+852 5550 0100', '13812345678'];
|
||||||
|
|
||||||
|
expect(formatPhoneNumbers(numbers, false)).toBe('+852 •••• 0100、138 •••• 5678');
|
||||||
|
expect(formatPhoneNumbers(numbers, true)).toBe('+852 5550 0100、13812345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fully masks values that are too short to expose safely', () => {
|
||||||
|
expect(maskPhoneNumber('1234')).toBe('••••');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const MASK = '••••';
|
||||||
|
const TWO_DIGIT_CALLING_CODES = new Set([
|
||||||
|
'20',
|
||||||
|
'27',
|
||||||
|
'30',
|
||||||
|
'31',
|
||||||
|
'32',
|
||||||
|
'33',
|
||||||
|
'34',
|
||||||
|
'36',
|
||||||
|
'39',
|
||||||
|
'40',
|
||||||
|
'41',
|
||||||
|
'43',
|
||||||
|
'44',
|
||||||
|
'45',
|
||||||
|
'46',
|
||||||
|
'47',
|
||||||
|
'48',
|
||||||
|
'49',
|
||||||
|
'51',
|
||||||
|
'52',
|
||||||
|
'53',
|
||||||
|
'54',
|
||||||
|
'55',
|
||||||
|
'56',
|
||||||
|
'57',
|
||||||
|
'58',
|
||||||
|
'60',
|
||||||
|
'61',
|
||||||
|
'62',
|
||||||
|
'63',
|
||||||
|
'64',
|
||||||
|
'65',
|
||||||
|
'66',
|
||||||
|
'81',
|
||||||
|
'82',
|
||||||
|
'84',
|
||||||
|
'86',
|
||||||
|
'90',
|
||||||
|
'91',
|
||||||
|
'92',
|
||||||
|
'93',
|
||||||
|
'94',
|
||||||
|
'95',
|
||||||
|
'98',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function internationalPrefix(value: string, digits: string): string {
|
||||||
|
const separated = value.match(/^\+\s*(\d{1,3})(?:[\s-]+)/u)?.[1];
|
||||||
|
if (separated) return separated;
|
||||||
|
|
||||||
|
const prefixLength = ['1', '7'].includes(digits[0] ?? '')
|
||||||
|
? 1
|
||||||
|
: TWO_DIGIT_CALLING_CODES.has(digits.slice(0, 2))
|
||||||
|
? 2
|
||||||
|
: 3;
|
||||||
|
return digits.slice(0, prefixLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhoneNumber(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const digits = trimmed.replace(/\D/gu, '');
|
||||||
|
if (digits.length <= 4) return MASK;
|
||||||
|
|
||||||
|
const suffix = digits.slice(-4);
|
||||||
|
if (trimmed.startsWith('+')) {
|
||||||
|
const prefix = internationalPrefix(trimmed, digits);
|
||||||
|
return `+${prefix} ${MASK} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${digits.slice(0, 3)} ${MASK} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhoneNumbers(values: readonly string[], revealed: boolean): string {
|
||||||
|
return values.map((value) => (revealed ? value : maskPhoneNumber(value))).join('、');
|
||||||
|
}
|
||||||
+17
-4
@@ -3,16 +3,29 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import 'animal-island-ui/style';
|
import 'animal-island-ui/style';
|
||||||
|
|
||||||
import { ConsoleAuthGate } from './auth/console-auth.js';
|
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';
|
import './styles.css';
|
||||||
|
|
||||||
const root = document.querySelector<HTMLElement>('#root');
|
const root = document.querySelector<HTMLElement>('#root');
|
||||||
if (!root) throw new Error('Missing application 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(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ConsoleAuthGate>
|
<BrowserApp />
|
||||||
<AppShell pathname={window.location.pathname} />
|
|
||||||
</ConsoleAuthGate>
|
|
||||||
</StrictMode>,
|
</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;
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ describe('Settings instances page', () => {
|
|||||||
it('is explicitly unavailable without an injected fleet source', () => {
|
it('is explicitly unavailable without an injected fleet source', () => {
|
||||||
render(<InstanceSettingsPage />);
|
render(<InstanceSettingsPage />);
|
||||||
expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
|
expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
|
||||||
|
expect(screen.queryByText('配置此工作区可用的 SimAdmin 实例。')).toBeNull();
|
||||||
expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
|
expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
|
||||||
/未提供实例总览数据源/i,
|
/未提供实例总览数据源/i,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ export function InstanceSettingsPage({
|
|||||||
<h1 id="settings-instances-title">
|
<h1 id="settings-instances-title">
|
||||||
<Title color="app-green">实例</Title>
|
<Title color="app-green">实例</Title>
|
||||||
</h1>
|
</h1>
|
||||||
<p>配置此工作区可用的 SimAdmin 实例。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="/instances/new">添加实例</a>
|
<a href="/instances/new">添加实例</a>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+97
-104
@@ -253,11 +253,6 @@ code {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.product-copy small {
|
|
||||||
color: #91a3b9;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -346,30 +341,6 @@ main {
|
|||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
box-shadow: 0 5px 22px rgba(20, 35, 55, 0.055);
|
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 */
|
/* Shared page headers */
|
||||||
.fleet-heading,
|
.fleet-heading,
|
||||||
main > section > header,
|
main > section > header,
|
||||||
@@ -384,16 +355,13 @@ main > section > header,
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.fleet-heading p,
|
|
||||||
main > section > header p,
|
main > section > header p,
|
||||||
.page-heading p {
|
.page-heading p {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.page-kicker,
|
.page-kicker,
|
||||||
.eyebrow,
|
.eyebrow {
|
||||||
.fleet-sidebar-eyebrow,
|
|
||||||
.fleet-results-eyebrow {
|
|
||||||
margin: 0 0 0.18rem !important;
|
margin: 0 0 0.18rem !important;
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
@@ -625,17 +593,6 @@ main > section > header p,
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
.fleet-sidebar-result-count {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.45rem 0.6rem;
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f4f6f8;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
|
||||||
.fleet-sidebar-result-count strong {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.fleet-active-filters {
|
.fleet-active-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -1722,10 +1679,6 @@ main ul[aria-label='已配置实例'] > li {
|
|||||||
.auth-card input {
|
.auth-card input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.auth-card .eyebrow {
|
|
||||||
color: #8eabff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 70rem) {
|
@media (max-width: 70rem) {
|
||||||
.fleet-workspace,
|
.fleet-workspace,
|
||||||
.fleet-workspace.is-sidebar-collapsed {
|
.fleet-workspace.is-sidebar-collapsed {
|
||||||
@@ -1775,7 +1728,6 @@ main ul[aria-label='已配置实例'] > li {
|
|||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
padding: 0.55rem 0.75rem;
|
padding: 0.55rem 0.75rem;
|
||||||
}
|
}
|
||||||
.product-copy small,
|
|
||||||
.connection-status {
|
.connection-status {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1901,11 +1853,6 @@ body,
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-copy small {
|
|
||||||
color: var(--workbench-muted);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-navigation {
|
.global-navigation {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -2079,10 +2026,6 @@ main {
|
|||||||
border-bottom: 1px solid var(--workbench-border);
|
border-bottom: 1px solid var(--workbench-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fleet-results-eyebrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fleet-card-grid {
|
.fleet-card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -2409,12 +2352,6 @@ main {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.drawer-eyebrow {
|
|
||||||
color: var(--workbench-mint);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
width: 2.75rem;
|
width: 2.75rem;
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
@@ -2579,9 +2516,6 @@ main {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0.45rem 0.65rem;
|
padding: 0.45rem 0.65rem;
|
||||||
}
|
}
|
||||||
.product-copy small {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -2838,10 +2772,6 @@ h1 {
|
|||||||
font-size: 1.04rem;
|
font-size: 1.04rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-copy small {
|
|
||||||
color: #75664f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-navigation {
|
.global-navigation {
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.32rem;
|
padding: 0.32rem;
|
||||||
@@ -2925,26 +2855,6 @@ main {
|
|||||||
box-shadow: none;
|
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,
|
.fleet-heading,
|
||||||
main > section > header,
|
main > section > header,
|
||||||
.page-heading {
|
.page-heading {
|
||||||
@@ -2961,9 +2871,7 @@ main > section > header h1,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-kicker,
|
.page-kicker,
|
||||||
.eyebrow,
|
.eyebrow {
|
||||||
.fleet-sidebar-eyebrow,
|
|
||||||
.fleet-results-eyebrow {
|
|
||||||
color: #138f85 !important;
|
color: #138f85 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3115,10 +3023,6 @@ main > section > header h1,
|
|||||||
border-bottom: 2px dashed #cabfa9;
|
border-bottom: 2px dashed #cabfa9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fleet-results-eyebrow {
|
|
||||||
color: #725d42 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fleet-card-grid {
|
.fleet-card-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -3764,10 +3668,6 @@ main ul[aria-label='已配置实例'] > li > div[class*='animal-'] {
|
|||||||
color: #725d42;
|
color: #725d42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card .eyebrow {
|
|
||||||
color: #138f85 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -3894,7 +3794,6 @@ svg {
|
|||||||
}
|
}
|
||||||
.fleet-sidebar-header strong,
|
.fleet-sidebar-header strong,
|
||||||
.fleet-status-summary span,
|
.fleet-status-summary span,
|
||||||
.fleet-results-eyebrow,
|
|
||||||
.fleet-card-kicker,
|
.fleet-card-kicker,
|
||||||
.fleet-card-metrics dt,
|
.fleet-card-metrics dt,
|
||||||
.fleet-card-sms h3,
|
.fleet-card-sms h3,
|
||||||
@@ -3906,7 +3805,6 @@ svg {
|
|||||||
}
|
}
|
||||||
.fleet-sidebar-header strong svg,
|
.fleet-sidebar-header strong svg,
|
||||||
.fleet-status-summary span svg,
|
.fleet-status-summary span svg,
|
||||||
.fleet-results-eyebrow svg,
|
|
||||||
.fleet-card-kicker svg,
|
.fleet-card-kicker svg,
|
||||||
.fleet-card-metrics dt svg,
|
.fleet-card-metrics dt svg,
|
||||||
.fleet-card-sms h3 svg,
|
.fleet-card-sms h3 svg,
|
||||||
@@ -4901,3 +4799,98 @@ svg {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phone privacy controls share one stable row across all fleet card widths. */
|
||||||
|
.fleet-card-hardware {
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(5.25rem, 0.65fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.72rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-hardware dt,
|
||||||
|
.fleet-card-hardware dd {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-hardware .fleet-card-phone {
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-hardware .fleet-card-phone dd {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-phone-value {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle,
|
||||||
|
.fleet-phone-privacy-toggle:hover:not(:disabled),
|
||||||
|
.fleet-phone-privacy-toggle:active:not(:disabled) {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
flex: 0 0 2.5rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--workbench-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--workbench-muted);
|
||||||
|
background: #fffef9;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: -0.125rem;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle:hover:not(:disabled) {
|
||||||
|
color: var(--workbench-text);
|
||||||
|
border-color: var(--workbench-border-strong);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle[aria-pressed='true'] {
|
||||||
|
color: var(--workbench-mint);
|
||||||
|
border-color: rgba(17, 119, 110, 0.36);
|
||||||
|
background: #f1faf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--workbench-mint);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-phone-privacy-toggle svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-footer-primary > span:last-child {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 80rem) {
|
||||||
|
.fleet-card-footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-footer-time {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type IconName =
|
|||||||
| 'globe'
|
| 'globe'
|
||||||
| 'grid'
|
| 'grid'
|
||||||
| 'history'
|
| 'history'
|
||||||
|
| 'eye'
|
||||||
|
| 'eye-off'
|
||||||
| 'jobs'
|
| 'jobs'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
| 'message'
|
| 'message'
|
||||||
@@ -42,6 +44,16 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
|
|||||||
'chevron-right': ['m9 18 6-6-6-6'],
|
'chevron-right': ['m9 18 6-6-6-6'],
|
||||||
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
|
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
|
||||||
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
|
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
|
||||||
|
eye: [
|
||||||
|
'M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z',
|
||||||
|
'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z',
|
||||||
|
],
|
||||||
|
'eye-off': [
|
||||||
|
'M3 3l18 18',
|
||||||
|
'M10.6 6.2A10.7 10.7 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-2.1 2.8',
|
||||||
|
'M6.1 6.1C3.7 7.8 2.5 12 2.5 12s3.5 6 9.5 6a10.7 10.7 0 0 0 3-.4',
|
||||||
|
'M9.9 9.9a3 3 0 0 0 4.2 4.2',
|
||||||
|
],
|
||||||
globe: [
|
globe: [
|
||||||
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
||||||
'M3 12h18',
|
'M3 12h18',
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# Phone Privacy And Release Polish 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 node phone data private by default, align the phone field, unify browser branding, and remove the approved redundant release copy across the application.
|
||||||
|
|
||||||
|
**Architecture:** Add one pure phone-display formatter beside the Fleet feature, then keep per-instance disclosure state inside `FleetPage`. Reuse the existing icon component and card DOM rather than introducing a new component system. Treat favicon and release-copy changes as static branding/content changes with source-level and browser-level regression coverage.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, CSS, Vite, SVG, Chrome DevTools Protocol E2E.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Both the card's own phone number and latest-SMS phone number are masked by default.
|
||||||
|
- One per-card eye button reveals or hides both phone locations for only that instance.
|
||||||
|
- Disclosure state is memory-only and resets after a page reload.
|
||||||
|
- Masked international example: `+852 •••• 0100`; masked local example: `138 •••• 5678`.
|
||||||
|
- The eye button has a stable 40-by-40-pixel hit target and cannot move the temperature field.
|
||||||
|
- The favicon uses the warm yellow and brown header-brand language.
|
||||||
|
- The release is non-commercial; `animal-island-ui` attribution and `CC BY-NC 4.0` remain visible and linked.
|
||||||
|
- Do not change APIs, card column counts, navigation, the left Fleet sidebar position, instance-detail phone displays, or SMS conversation displays.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Pure Phone Masking
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/src/fleet/phone-privacy.ts`
|
||||||
|
- Create: `apps/web/src/fleet/phone-privacy.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `maskPhoneNumber(phoneNumber: string): string`.
|
||||||
|
- Produces: `formatPhoneNumbers(phoneNumbers: readonly string[], revealed: boolean): string`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing formatter tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(maskPhoneNumber('+852 5550 0100')).toBe('+852 •••• 0100');
|
||||||
|
expect(maskPhoneNumber('13812345678')).toBe('138 •••• 5678');
|
||||||
|
expect(formatPhoneNumbers(['+852 5550 0100', '13812345678'], false)).toBe(
|
||||||
|
'+852 •••• 0100、138 •••• 5678',
|
||||||
|
);
|
||||||
|
expect(formatPhoneNumbers(['+852 5550 0100'], true)).toBe('+852 5550 0100');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the tests fail because the module does not exist**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `phone-privacy.ts` or its exports are missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal pure formatter**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MASK = '••••';
|
||||||
|
|
||||||
|
export function maskPhoneNumber(phoneNumber: string): string {
|
||||||
|
const trimmed = phoneNumber.trim();
|
||||||
|
const digits = trimmed.replace(/\D/gu, '');
|
||||||
|
if (digits.length <= 4) return MASK;
|
||||||
|
const suffix = digits.slice(-4);
|
||||||
|
if (trimmed.startsWith('+')) {
|
||||||
|
const groupedPrefix = /^\+\d{1,3}(?=[\s-])/u.exec(trimmed)?.[0];
|
||||||
|
const prefix = groupedPrefix ?? `+${digits.slice(0, 3)}`;
|
||||||
|
return `${prefix} ${MASK} ${suffix}`;
|
||||||
|
}
|
||||||
|
return `${digits.slice(0, 3)} ${MASK} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhoneNumbers(
|
||||||
|
phoneNumbers: readonly string[],
|
||||||
|
revealed: boolean,
|
||||||
|
): string {
|
||||||
|
return phoneNumbers.map((number) => (revealed ? number : maskPhoneNumber(number))).join('、');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the formatter tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the formatter**
|
||||||
|
|
||||||
|
Run: `git add apps/web/src/fleet/phone-privacy.ts apps/web/src/fleet/phone-privacy.test.ts && git commit -m "feat(web): add phone privacy formatter"`
|
||||||
|
|
||||||
|
### Task 2: Per-Card Disclosure And Stable Phone Layout
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||||
|
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||||
|
- Modify: `apps/web/src/ui/icon.tsx`
|
||||||
|
- Modify: `apps/web/src/styles.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `formatPhoneNumbers(phoneNumbers, revealed)` from Task 1.
|
||||||
|
- Produces: per-instance `revealedPhoneIds: Set<string>` state in `FleetPage`.
|
||||||
|
- Produces: `IconName` values `eye` and `eye-off` through the existing icon component.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing card behavior tests**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
expect(within(firstCard).getByText('+852 •••• 0100')).toBeTruthy();
|
||||||
|
expect(within(firstCard).queryByText('+852 5550 0100')).toBeNull();
|
||||||
|
expect(within(firstCard).getByText('+852 •••• 0199')).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(within(firstCard).getByRole('button', { name: /显示.*手机号/u }));
|
||||||
|
expect(within(firstCard).getByText('+852 5550 0100')).toBeTruthy();
|
||||||
|
expect(within(firstCard).getByText('+852 5550 0199')).toBeTruthy();
|
||||||
|
expect(within(secondCard).queryByText('+852 5550 0200')).toBeNull();
|
||||||
|
|
||||||
|
await user.click(within(firstCard).getByRole('button', { name: /隐藏.*手机号/u }));
|
||||||
|
expect(within(firstCard).queryByText('+852 5550 0100')).toBeNull();
|
||||||
|
```
|
||||||
|
|
||||||
|
Also assert that a card without either phone source has no disclosure button.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the Fleet test and verify privacy assertions fail**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/fleet-page.test.tsx`
|
||||||
|
|
||||||
|
Expected: FAIL because raw numbers are still rendered and no disclosure button exists.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add eye icons and per-instance disclosure state**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function togglePhoneVisibility(instanceId: string): void {
|
||||||
|
setRevealedPhoneIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(instanceId)) next.delete(instanceId);
|
||||||
|
else next.add(instanceId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render one `.fleet-card-phone-toggle` button in the hardware phone field when either phone source exists. Give it dynamic `aria-label`, `aria-pressed`, `title`, and `eye`/`eye-off` icon. Use the same `revealed` boolean for the card hardware value and latest-SMS phone text.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Stabilize the phone and temperature grid**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.fleet-card-hardware {
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(5rem, 0.65fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-phone-value {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-phone-toggle {
|
||||||
|
inline-size: 2.5rem;
|
||||||
|
block-size: 2.5rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep text within `min-width: 0`, use tabular numerals, and prevent the button from shrinking.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run Fleet behavior tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts src/fleet/fleet-page.test.tsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit card privacy behavior**
|
||||||
|
|
||||||
|
Run: `git add apps/web/src/fleet/fleet-page.tsx apps/web/src/fleet/fleet-page.test.tsx apps/web/src/ui/icon.tsx apps/web/src/styles.css && git commit -m "feat(web): protect fleet phone numbers"`
|
||||||
|
|
||||||
|
### Task 3: Browser Tab Branding
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/public/favicon.svg`
|
||||||
|
- Create: `apps/web/src/app-branding.test.ts`
|
||||||
|
- Modify: `apps/web/index.html`
|
||||||
|
- Modify: `scripts/real-browser-e2e.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `/favicon.svg` with a 64-by-64 warm signal badge.
|
||||||
|
- Produces: title `SimAdmin Control · 多节点控制台` and theme color `#f3f0e7`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing branding source and browser assertions**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(indexHtml).toContain('href="/favicon.svg"');
|
||||||
|
expect(indexHtml).toContain('content="#f3f0e7"');
|
||||||
|
expect(indexHtml).toContain('<title>SimAdmin Control · 多节点控制台</title>');
|
||||||
|
expect(faviconSvg).toContain('#f6c95f');
|
||||||
|
expect(faviconSvg).toContain('#725d42');
|
||||||
|
```
|
||||||
|
|
||||||
|
Add E2E assertions for `document.title`, the favicon link pathname, and the theme-color value.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run branding tests and verify they fail**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-branding.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL on the old inline blue favicon, dark theme color, and corrupted title.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the static warm favicon and update `index.html`**
|
||||||
|
|
||||||
|
Create a compact SVG with a warm yellow rounded square, brown border, and three ascending rounded bars. Replace the data-URI favicon, theme color, and title with the exact values above.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run branding tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-branding.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit browser branding**
|
||||||
|
|
||||||
|
Run: `git add apps/web/index.html apps/web/public/favicon.svg apps/web/src/app-branding.test.ts scripts/real-browser-e2e.mjs && git commit -m "feat(web): align browser branding"`
|
||||||
|
|
||||||
|
### Task 4: Release Copy Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/src/app-shell.integration.test.tsx`
|
||||||
|
- Modify: `apps/web/src/app-shell.tsx`
|
||||||
|
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||||
|
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||||
|
- Modify: `apps/web/src/automation/automation-page.test.tsx`
|
||||||
|
- Modify: `apps/web/src/automation/automation-page.tsx`
|
||||||
|
- Modify: `apps/web/src/settings/instance-settings-page.test.tsx`
|
||||||
|
- Modify: `apps/web/src/settings/instance-settings-page.tsx`
|
||||||
|
- Modify: `apps/web/src/auth/console-auth.test.tsx`
|
||||||
|
- Modify: `apps/web/src/auth/console-auth.tsx`
|
||||||
|
- Modify: `apps/web/src/auth/console-auth-settings.tsx`
|
||||||
|
- Modify: `apps/web/src/styles.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the exact removal and retention lists in the design specification.
|
||||||
|
- Produces: a shorter linked footer attribution that still names `animal-island-ui` and `CC BY-NC 4.0`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing content assertions**
|
||||||
|
|
||||||
|
Assert that the approved redundant strings are absent and that required copy remains:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
expect(screen.queryByText('NODE DIRECTORY')).toBeNull();
|
||||||
|
expect(screen.queryByText(/LIVE NODE MATRIX/u)).toBeNull();
|
||||||
|
expect(screen.queryByText(/按北京时间(UTC\+8)统一调度/u)).toBeNull();
|
||||||
|
expect(screen.queryByText('SYSTEM SETTINGS')).toBeNull();
|
||||||
|
expect(screen.queryByText('MULTI SIMADMIN')).toBeNull();
|
||||||
|
expect(screen.getByText(/HTTP/u)).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: 'animal-island-ui' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: 'CC BY-NC 4.0' })).toBeTruthy();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the affected component tests and verify they fail**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-shell.integration.test.tsx src/fleet/fleet-page.test.tsx src/automation/automation-page.test.tsx src/settings/instance-settings-page.test.tsx src/auth/console-auth.test.tsx`
|
||||||
|
|
||||||
|
Expected: FAIL while the redundant strings remain or the compact footer is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove only the approved visible copy**
|
||||||
|
|
||||||
|
Delete the approved header descriptions, English eyebrow labels, duplicate Fleet counts and status explanations, Automation empty-state descriptions and drawer eyebrow, Settings descriptions, and login eyebrow/description. Keep loading, error, risk, security, timezone-near-Cron, SMS preservation, and empty-state titles.
|
||||||
|
|
||||||
|
Replace the footer with two explicit links:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<p>
|
||||||
|
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove styles made unused by this change**
|
||||||
|
|
||||||
|
Remove selectors only when their final DOM owner was deleted, including the obsolete brand subtitle and removed eyebrow-specific declarations. Do not refactor unrelated style cascades.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run affected tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-shell.integration.test.tsx src/fleet/fleet-page.test.tsx src/automation/automation-page.test.tsx src/settings/instance-settings-page.test.tsx src/auth/console-auth.test.tsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit release copy cleanup**
|
||||||
|
|
||||||
|
Run: `git add apps/web/src/app-shell.tsx apps/web/src/app-shell.integration.test.tsx apps/web/src/fleet/fleet-page.tsx apps/web/src/fleet/fleet-page.test.tsx apps/web/src/automation/automation-page.tsx apps/web/src/automation/automation-page.test.tsx apps/web/src/settings/instance-settings-page.tsx apps/web/src/settings/instance-settings-page.test.tsx apps/web/src/auth/console-auth.tsx apps/web/src/auth/console-auth.test.tsx apps/web/src/auth/console-auth-settings.tsx apps/web/src/styles.css && git commit -m "refactor(web): trim release copy"`
|
||||||
|
|
||||||
|
### Task 5: Responsive Browser Verification And Release Gates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/real-browser-e2e.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.fleet-card-phone-value`, `.fleet-card-phone-toggle`, page title, favicon, and the existing five viewport fixtures.
|
||||||
|
- Produces: regression assertions and screenshots for 390, 768, 1024, 1440, and 1920 pixels.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend E2E geometry and privacy assertions**
|
||||||
|
|
||||||
|
For every viewport, assert that raw phone fixtures are absent before disclosure, the masked value is present, the toggle is 40 by 40 pixels, and the phone value/toggle/temperature rectangles remain inside the hardware panel without overlap. At 1440 pixels, click the first card toggle and assert both raw numbers appear only in that card.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run Chrome E2E and correct only failures caused by this feature**
|
||||||
|
|
||||||
|
Run: `$env:E2E_SCREENSHOT_DIR='C:\Users\86135\Downloads\multi-simadmin\artifacts\phone-privacy-release'; corepack pnpm run test:e2e:browser`
|
||||||
|
|
||||||
|
Expected: PASS across all five viewports.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rebuild the production web bundle after E2E cleanup**
|
||||||
|
|
||||||
|
Run: `corepack pnpm --filter @multi-simadmin/web build`
|
||||||
|
|
||||||
|
Expected: Vite build succeeds and recreates `apps/web/dist`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run changed-scope and static quality gates**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
corepack pnpm --filter @multi-simadmin/web test
|
||||||
|
corepack pnpm run typecheck
|
||||||
|
corepack pnpm run lint
|
||||||
|
corepack pnpm run format:check
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Web tests, typecheck, lint, formatting, and diff checks pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the running local service**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/
|
||||||
|
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/api/v1/instances
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both requests return HTTP 200.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit E2E coverage after all gates pass**
|
||||||
|
|
||||||
|
Run: `git add scripts/real-browser-e2e.mjs docs/superpowers/plans/2026-07-30-phone-privacy-release-polish.md && git commit -m "test(web): verify phone privacy release polish"`
|
||||||
@@ -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,105 @@
|
|||||||
|
# 手机号隐私与正式发布精简设计
|
||||||
|
|
||||||
|
日期:2026-07-30
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在不改变节点卡片核心信息架构的前提下,解决手机号区域对齐不规整的问题,为本机手机号和最新短信对方号码增加默认脱敏与单卡显隐控制,并统一浏览器标签页品牌。面向非商业公开发布,移除全站重复、内部化和解释性过强的可见文案,同时保留操作、状态、安全、错误、无障碍和许可信息。
|
||||||
|
|
||||||
|
## 手机号隐私
|
||||||
|
|
||||||
|
### 脱敏范围
|
||||||
|
|
||||||
|
- 节点卡片硬件信息区的本机手机号。
|
||||||
|
- 同一卡片底部最新短信中的对方号码。
|
||||||
|
- 原始号码不得在默认可见文本、标题属性或无障碍名称中泄露。
|
||||||
|
|
||||||
|
### 掩码格式
|
||||||
|
|
||||||
|
- 带国际前缀的号码保留加号开头的首个原始分组和末四位,例如 `+852 •••• 0100`;无分隔符时保留 `+` 与其后三位。
|
||||||
|
- 不带国际前缀的号码保留前三位和末四位,例如 `138 •••• 5678`。
|
||||||
|
- 中间部分统一显示四个圆点,避免不同号码长度造成布局跳动。
|
||||||
|
- 多个本机号码分别脱敏后使用现有分隔方式展示。
|
||||||
|
- 没有号码时继续显示“暂未获取”,且不显示显隐按钮。
|
||||||
|
|
||||||
|
### 显隐交互
|
||||||
|
|
||||||
|
- 每张卡片只有一个眼睛图标按钮,固定在本机手机号区域右侧。
|
||||||
|
- 默认状态为隐藏;点击后同时显示该卡片的本机手机号和最新短信号码,再次点击恢复脱敏。
|
||||||
|
- 状态按实例 ID 隔离,切换一张卡片不得影响其他卡片。
|
||||||
|
- 筛选、排序和分页期间保留当前页面会话中的显隐状态;刷新页面后恢复默认隐藏。
|
||||||
|
- 按钮使用眼睛与闭眼图标、悬浮提示、动态 `aria-label` 和 `aria-pressed`,不使用额外文字按钮。
|
||||||
|
|
||||||
|
## 手机号布局
|
||||||
|
|
||||||
|
- 硬件信息区继续保留“手机号”和“最高温度”两个字段。
|
||||||
|
- 网格调整为手机号较宽、温度较窄的稳定比例,手机号值与 40 像素显隐按钮使用固定的 `minmax(0, 1fr) auto` 子网格。
|
||||||
|
- 号码文本不得推动按钮、温度字段或卡片宽度;窄屏允许号码在自身区域内安全收缩,不产生横向滚动。
|
||||||
|
- 最新短信区域不增加第二个按钮,显示状态完全跟随卡片的唯一显隐按钮。
|
||||||
|
|
||||||
|
## 标签页品牌
|
||||||
|
|
||||||
|
- 新增独立的 `favicon.svg`,使用顶部品牌一致的暖黄色圆角底、棕色三段信号柱和暖色描边。
|
||||||
|
- `index.html` 删除现有蓝色内嵌 favicon,改为引用静态资源。
|
||||||
|
- 浏览器主题色改为工作台暖米白色。
|
||||||
|
- 标签页标题统一为 `SimAdmin Control · 多节点控制台`。
|
||||||
|
|
||||||
|
## 全站文案精简
|
||||||
|
|
||||||
|
### 全局
|
||||||
|
|
||||||
|
- 删除品牌名下方的副标题。
|
||||||
|
- 底部署名缩短为 `UI: animal-island-ui · CC BY-NC 4.0`,保留项目链接与许可链接。
|
||||||
|
- 保留非商业使用所需的署名,不删除许可信息。
|
||||||
|
|
||||||
|
### 节点页
|
||||||
|
|
||||||
|
- 删除页头说明句。
|
||||||
|
- 删除 `NODE DIRECTORY` 和 `LIVE NODE MATRIX`。
|
||||||
|
- 删除侧栏重复的“显示 x/x”,结果区保留唯一的数量与分页信息。
|
||||||
|
- 删除实例总数、在线和需处理数字下方的三句重复解释。
|
||||||
|
- 保留节点总览、筛选、状态、操作、批量风险提示、加载、错误和空状态。
|
||||||
|
|
||||||
|
### 自动化页
|
||||||
|
|
||||||
|
- 删除页头说明句。
|
||||||
|
- 删除抽屉中与主标题重复的“任务设置”和“新建自动化”眉题。
|
||||||
|
- 计划任务、执行记录和操作审计的空状态只保留简短状态标题。
|
||||||
|
- 保留 Cron 附近的北京时间说明、短信保留规则、高频风险提示和最终确认。
|
||||||
|
|
||||||
|
### 设置页
|
||||||
|
|
||||||
|
- 删除实例设置页说明句。
|
||||||
|
- 删除密码保护页的 `SYSTEM SETTINGS` 和重复介绍。
|
||||||
|
- 保留 HTTP/HTTPS 安全警告、密码配置状态、保存结果、加载与错误提示。
|
||||||
|
|
||||||
|
### 登录页
|
||||||
|
|
||||||
|
- 删除 `MULTI SIMADMIN` 和重复的登录说明。
|
||||||
|
- 保留登录标题、密码输入、提交按钮、加载与错误状态。
|
||||||
|
|
||||||
|
## 数据与状态边界
|
||||||
|
|
||||||
|
- 不修改 API、资源响应或短信数据结构。
|
||||||
|
- 显隐状态仅存在于前端内存中,不写入本地存储、URL、服务端或审计日志。
|
||||||
|
- 掩码函数只负责显示转换,不改变原始数据对象。
|
||||||
|
- favicon 和文案调整不改变路由、权限或操作流程。
|
||||||
|
|
||||||
|
## 测试与验收
|
||||||
|
|
||||||
|
- 单元测试验证默认状态只显示掩码,不出现原始本机号码或最新短信号码。
|
||||||
|
- 单元测试验证单卡按钮同时显示两处号码、再次点击恢复隐藏,并且不影响其他卡片。
|
||||||
|
- 单元测试验证国际前缀、本地号码、多个号码和无号码状态。
|
||||||
|
- 集成或浏览器测试验证眼睛按钮的无障碍名称、按下状态、40 像素稳定尺寸和响应式边界。
|
||||||
|
- 浏览器测试验证 favicon、主题色和标签页标题。
|
||||||
|
- 内容测试验证确认删除的冗余文案不再渲染,同时安全提示、错误状态和许可署名仍存在。
|
||||||
|
- 在 390、768、1024、1440 和 1920 像素宽度检查卡片无横向溢出、按钮位置稳定、号码与温度不重叠。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不增加全局号码显隐开关。
|
||||||
|
- 不在号码之间提供独立显隐按钮。
|
||||||
|
- 不持久化号码显隐偏好。
|
||||||
|
- 不修改实例详情页或短信会话页的号码展示。
|
||||||
|
- 不移除功能标签、安全提示、无障碍文本或许可署名。
|
||||||
|
- 不改变现有颜色系统、卡片列数、左侧总览位置或核心导航。
|
||||||
@@ -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。
|
||||||
|
- 不重构节点、自动化或安全设置页面的视觉设计。
|
||||||
+178
-19
@@ -19,6 +19,7 @@ const FORBIDDEN_PORT = 8788;
|
|||||||
const EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN);
|
const EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN);
|
||||||
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR?.trim();
|
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR?.trim();
|
||||||
const FLEET_FIXTURE = process.env.E2E_FLEET_FIXTURE !== '0';
|
const FLEET_FIXTURE = process.env.E2E_FLEET_FIXTURE !== '0';
|
||||||
|
const requestPathnames = [];
|
||||||
|
|
||||||
const CHROME_CANDIDATES = [
|
const CHROME_CANDIDATES = [
|
||||||
process.env.CHROME_BIN,
|
process.env.CHROME_BIN,
|
||||||
@@ -77,11 +78,20 @@ const FLEET_FIXTURE_ITEMS = [
|
|||||||
|
|
||||||
async function builtAssetHandler(request, response) {
|
async function builtAssetHandler(request, response) {
|
||||||
const pathname = new URL(request.url ?? '/', 'http://e2e.local').pathname;
|
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.statusCode = 204;
|
||||||
response.end();
|
response.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pathname === '/api/v1/events') {
|
||||||
|
response.writeHead(200, {
|
||||||
|
'content-type': 'text/event-stream',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
});
|
||||||
|
response.flushHeaders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let body;
|
let body;
|
||||||
if (pathname === '/api/v1/auth/status')
|
if (pathname === '/api/v1/auth/status')
|
||||||
body = { configured: false, protectionEnabled: false, authenticated: false };
|
body = { configured: false, protectionEnabled: false, authenticated: false };
|
||||||
@@ -102,8 +112,20 @@ async function builtAssetHandler(request, response) {
|
|||||||
phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`],
|
phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname))
|
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname)) {
|
||||||
body = { messages: [] };
|
const fixtureIndex = FLEET_FIXTURE_ITEMS.findIndex((item) => pathname.includes(item.id));
|
||||||
|
body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: `sms-${Math.max(0, fixtureIndex)}`,
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: `1390013900${Math.max(0, fixtureIndex)}`,
|
||||||
|
content: 'status',
|
||||||
|
timestamp: '2026-07-30T08:30:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
pathname === '/api/v1/jobs' ||
|
pathname === '/api/v1/jobs' ||
|
||||||
pathname === '/api/v1/audit' ||
|
pathname === '/api/v1/audit' ||
|
||||||
@@ -134,7 +156,10 @@ async function builtAssetHandler(request, response) {
|
|||||||
response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 }));
|
response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relative = pathname.startsWith('/assets/') ? pathname.slice(1) : 'index.html';
|
const relative =
|
||||||
|
pathname.startsWith('/assets/') || pathname === '/favicon.svg'
|
||||||
|
? pathname.slice(1)
|
||||||
|
: 'index.html';
|
||||||
try {
|
try {
|
||||||
const asset = await readFile(join(DIST, relative));
|
const asset = await readFile(join(DIST, relative));
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
@@ -144,6 +169,7 @@ async function builtAssetHandler(request, response) {
|
|||||||
'.html': 'text/html; charset=utf-8',
|
'.html': 'text/html; charset=utf-8',
|
||||||
'.js': 'text/javascript; charset=utf-8',
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml; charset=utf-8',
|
||||||
}[extname(relative)] ?? 'application/octet-stream',
|
}[extname(relative)] ?? 'application/octet-stream',
|
||||||
);
|
);
|
||||||
response.end(asset);
|
response.end(asset);
|
||||||
@@ -396,9 +422,15 @@ async function fleetLayout(cdp) {
|
|||||||
const firstResource = document.querySelector('.fleet-resource-row');
|
const firstResource = document.querySelector('.fleet-resource-row');
|
||||||
const telemetry = document.querySelector('.fleet-card-telemetry');
|
const telemetry = document.querySelector('.fleet-card-telemetry');
|
||||||
const footer = document.querySelector('.fleet-card-footer');
|
const footer = document.querySelector('.fleet-card-footer');
|
||||||
|
const footerNumber = document.querySelector('.fleet-card-footer-primary > span:last-child');
|
||||||
const menuTrigger = document.querySelector('.fleet-card-menu-trigger');
|
const menuTrigger = document.querySelector('.fleet-card-menu-trigger');
|
||||||
const menuPanel = document.querySelector('.fleet-card-menu-panel');
|
const menuPanel = document.querySelector('.fleet-card-menu-panel');
|
||||||
const checkbox = document.querySelector('.fleet-card-select');
|
const checkbox = document.querySelector('.fleet-card-select');
|
||||||
|
const hardware = document.querySelector('.fleet-card-hardware');
|
||||||
|
const phoneField = document.querySelector('.fleet-card-phone');
|
||||||
|
const phoneValue = document.querySelector('.fleet-card-phone-value');
|
||||||
|
const phoneToggle = document.querySelector('.fleet-phone-privacy-toggle');
|
||||||
|
const temperatureField = document.querySelector('.fleet-card-hardware > div:not(.fleet-card-phone)');
|
||||||
const bounds = (element) => {
|
const bounds = (element) => {
|
||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -406,12 +438,14 @@ async function fleetLayout(cdp) {
|
|||||||
};
|
};
|
||||||
const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => {
|
const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => {
|
||||||
const label = item.querySelector('dt');
|
const label = item.querySelector('dt');
|
||||||
const value = item.querySelector('dd');
|
const value = item.querySelector('.fleet-card-phone-value') ?? item.querySelector('dd');
|
||||||
return {
|
return {
|
||||||
labelHeight: label?.getBoundingClientRect().height ?? 0,
|
labelHeight: label?.getBoundingClientRect().height ?? 0,
|
||||||
labelFits: label ? label.scrollWidth <= label.clientWidth : false,
|
labelFits: label ? label.scrollWidth <= label.clientWidth : false,
|
||||||
valueHeight: value?.getBoundingClientRect().height ?? 0,
|
valueHeight: value?.getBoundingClientRect().height ?? 0,
|
||||||
valueFits: value ? value.scrollWidth <= value.clientWidth : false,
|
valueFits: value ? value.scrollWidth <= value.clientWidth : false,
|
||||||
|
valueClientWidth: value?.clientWidth ?? 0,
|
||||||
|
valueScrollWidth: value?.scrollWidth ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => {
|
const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => {
|
||||||
@@ -438,9 +472,16 @@ async function fleetLayout(cdp) {
|
|||||||
firstResource: bounds(firstResource),
|
firstResource: bounds(firstResource),
|
||||||
telemetry: bounds(telemetry),
|
telemetry: bounds(telemetry),
|
||||||
footer: bounds(footer),
|
footer: bounds(footer),
|
||||||
|
footerNumber: bounds(footerNumber),
|
||||||
|
footerNumberFits: footerNumber ? footerNumber.scrollWidth <= footerNumber.clientWidth : false,
|
||||||
menuTrigger: bounds(menuTrigger),
|
menuTrigger: bounds(menuTrigger),
|
||||||
menuPanel: bounds(menuPanel),
|
menuPanel: bounds(menuPanel),
|
||||||
checkbox: bounds(checkbox),
|
checkbox: bounds(checkbox),
|
||||||
|
hardware: bounds(hardware),
|
||||||
|
phoneField: bounds(phoneField),
|
||||||
|
phoneValue: bounds(phoneValue),
|
||||||
|
phoneToggle: bounds(phoneToggle),
|
||||||
|
temperatureField: bounds(temperatureField),
|
||||||
hardwareEntries,
|
hardwareEntries,
|
||||||
navigation,
|
navigation,
|
||||||
};
|
};
|
||||||
@@ -575,6 +616,31 @@ try {
|
|||||||
`document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`,
|
`document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`,
|
||||||
`Fleet SimAdmin version did not synchronize at ${viewport.width}px`,
|
`Fleet SimAdmin version did not synchronize at ${viewport.width}px`,
|
||||||
);
|
);
|
||||||
|
await eventually(
|
||||||
|
cdp,
|
||||||
|
`document.querySelector('.fleet-card-footer-primary')?.textContent.includes('139 •••• 9000')`,
|
||||||
|
`Fleet SMS phone did not load privately at ${viewport.width}px`,
|
||||||
|
);
|
||||||
|
if (viewport.width === 390) {
|
||||||
|
const branding = await evaluate(
|
||||||
|
cdp,
|
||||||
|
`(() => ({ title: document.title, favicon: new URL(document.querySelector('link[rel="icon"]').href).pathname, theme: document.querySelector('meta[name="theme-color"]').content }))()`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(branding, {
|
||||||
|
title: 'SimAdmin Control · 多节点控制台',
|
||||||
|
favicon: '/favicon.svg',
|
||||||
|
theme: '#f3f0e7',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const privacyText = await evaluate(
|
||||||
|
cdp,
|
||||||
|
`(() => ({ text: document.body.textContent, toggleCount: document.querySelectorAll('.fleet-phone-privacy-toggle').length }))()`,
|
||||||
|
);
|
||||||
|
assert.equal(privacyText.toggleCount, FLEET_FIXTURE_ITEMS.length);
|
||||||
|
assert.match(privacyText.text, /\+852 •••• 0100/u);
|
||||||
|
assert.match(privacyText.text, /139 •••• 9000/u);
|
||||||
|
assert.doesNotMatch(privacyText.text, /\+852 5550 100/u);
|
||||||
|
assert.doesNotMatch(privacyText.text, /13900139000/u);
|
||||||
const layout = await fleetLayout(cdp);
|
const layout = await fleetLayout(cdp);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layout.scrollWidth <= layout.viewport,
|
layout.scrollWidth <= layout.viewport,
|
||||||
@@ -585,7 +651,11 @@ try {
|
|||||||
assert.equal(layout.groupDisplay, 'flex', `${viewport.width}px tag groups must use flex`);
|
assert.equal(layout.groupDisplay, 'flex', `${viewport.width}px tag groups must use flex`);
|
||||||
assert.equal(layout.resourceDisplay, 'grid', `${viewport.width}px resource rows must use grid`);
|
assert.equal(layout.resourceDisplay, 'grid', `${viewport.width}px resource rows must use grid`);
|
||||||
assert.equal(layout.telemetryDisplay, 'grid', `${viewport.width}px telemetry must use grid`);
|
assert.equal(layout.telemetryDisplay, 'grid', `${viewport.width}px telemetry must use grid`);
|
||||||
assert.equal(layout.footerDisplay, 'flex', `${viewport.width}px SMS footer must use flex`);
|
assert.equal(
|
||||||
|
layout.footerDisplay,
|
||||||
|
viewport.width >= 1280 ? 'grid' : 'flex',
|
||||||
|
`${viewport.width}px SMS footer layout`,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layout.workspaceDisplay,
|
layout.workspaceDisplay,
|
||||||
'grid',
|
'grid',
|
||||||
@@ -604,7 +674,13 @@ try {
|
|||||||
layout.firstResource &&
|
layout.firstResource &&
|
||||||
layout.telemetry &&
|
layout.telemetry &&
|
||||||
layout.footer &&
|
layout.footer &&
|
||||||
layout.menuTrigger,
|
layout.footerNumber &&
|
||||||
|
layout.menuTrigger &&
|
||||||
|
layout.hardware &&
|
||||||
|
layout.phoneField &&
|
||||||
|
layout.phoneValue &&
|
||||||
|
layout.phoneToggle &&
|
||||||
|
layout.temperatureField,
|
||||||
`${viewport.width}px Fleet information hierarchy must render`,
|
`${viewport.width}px Fleet information hierarchy must render`,
|
||||||
);
|
);
|
||||||
for (const [name, bounds] of [
|
for (const [name, bounds] of [
|
||||||
@@ -651,16 +727,30 @@ try {
|
|||||||
`${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`,
|
`${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
|
layout.footerNumberFits,
|
||||||
layout.hardwareEntries.every(
|
|
||||||
(entry) =>
|
|
||||||
entry.labelFits &&
|
|
||||||
entry.valueFits &&
|
|
||||||
entry.labelHeight <= 20 &&
|
|
||||||
entry.valueHeight <= 20,
|
|
||||||
),
|
|
||||||
true,
|
true,
|
||||||
`${viewport.width}px hardware facts wrap or clip: ${JSON.stringify(layout.hardwareEntries)}`,
|
`${viewport.width}px latest SMS phone must remain fully visible`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Math.abs(layout.phoneToggle.width - 40) <= 0.5 &&
|
||||||
|
Math.abs(layout.phoneToggle.height - 40) <= 0.5,
|
||||||
|
true,
|
||||||
|
`${viewport.width}px privacy toggle must remain 40px: ${JSON.stringify(layout.phoneToggle)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
layout.phoneField.left >= layout.hardware.left - 0.5 &&
|
||||||
|
layout.temperatureField.right <= layout.hardware.right + 0.5 &&
|
||||||
|
layout.phoneValue.right <= layout.phoneToggle.left + 0.5 &&
|
||||||
|
layout.phoneToggle.right <= layout.phoneField.right + 0.5 &&
|
||||||
|
layout.phoneField.right < layout.temperatureField.left,
|
||||||
|
true,
|
||||||
|
`${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
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)}`,
|
||||||
);
|
);
|
||||||
if (viewport.sidebarMode === 'left') {
|
if (viewport.sidebarMode === 'left') {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -705,6 +795,21 @@ try {
|
|||||||
await captureScreenshot(cdp, 'warm-fleet-390-cards.png');
|
await captureScreenshot(cdp, 'warm-fleet-390-cards.png');
|
||||||
}
|
}
|
||||||
if (viewport.width === 1440) {
|
if (viewport.width === 1440) {
|
||||||
|
await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`);
|
||||||
|
await eventually(
|
||||||
|
cdp,
|
||||||
|
`document.querySelector('.fleet-card')?.textContent.includes('+852 5550 100') && document.querySelector('.fleet-card')?.textContent.includes('13900139000')`,
|
||||||
|
'Fleet phone disclosure did not reveal both phone locations',
|
||||||
|
);
|
||||||
|
const disclosedText = await evaluate(
|
||||||
|
cdp,
|
||||||
|
`[...document.querySelectorAll('.fleet-card')].map((card) => card.textContent)`,
|
||||||
|
);
|
||||||
|
assert.match(disclosedText[0], /\+852 5550 100/u);
|
||||||
|
assert.match(disclosedText[0], /13900139000/u);
|
||||||
|
assert.doesNotMatch(disclosedText[1], /\+852 5550/u);
|
||||||
|
assert.doesNotMatch(disclosedText[1], /1390013900/u);
|
||||||
|
await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`);
|
||||||
await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
|
await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
|
||||||
await eventually(
|
await eventually(
|
||||||
cdp,
|
cdp,
|
||||||
@@ -789,7 +894,51 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await assertWidePageGutters(cdp, 'Nodes');
|
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 assertWidePageGutters(cdp, 'Settings');
|
||||||
await keyboardNavigate(cdp, '自动化', '/automation', '自动化');
|
await keyboardNavigate(cdp, '自动化', '/automation', '自动化');
|
||||||
await assertWidePageGutters(cdp, 'Automation');
|
await assertWidePageGutters(cdp, 'Automation');
|
||||||
@@ -885,7 +1034,17 @@ try {
|
|||||||
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
|
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
|
||||||
'/audit alias did not select operation audit',
|
'/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')}`);
|
assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`);
|
||||||
console.log(`PASS real Chrome E2E (${chromeBinary})`);
|
console.log(`PASS real Chrome E2E (${chromeBinary})`);
|
||||||
@@ -895,7 +1054,7 @@ try {
|
|||||||
: `PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`,
|
: `PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`,
|
||||||
);
|
);
|
||||||
console.log(
|
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) {
|
} catch (error) {
|
||||||
console.error(error instanceof Error ? error.stack : error);
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
|||||||
Reference in New Issue
Block a user