feat: rebuild warm operations workbench
This commit is contained in:
+6
-1
@@ -4,7 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>多实例 SimAdmin 管理台</title>
|
||||
<meta name="theme-color" content="#142137" />
|
||||
<link
|
||||
rel="icon"
|
||||
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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@multi-simadmin/contracts": "workspace:*",
|
||||
"animal-island-ui": "1.3.0",
|
||||
"classnames": "2.5.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppShell, type InstanceContext } from './app-shell.js';
|
||||
import type { AuditDataSource } from './audit/audit-page.js';
|
||||
import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js';
|
||||
import type { EventStreamClient } from './events/event-stream-client.js';
|
||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||
import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js';
|
||||
@@ -70,6 +71,20 @@ const quietEventStreamClient: EventStreamClient = {
|
||||
|
||||
const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
|
||||
|
||||
function emptyScheduleDataSource(): ScheduleDataSource {
|
||||
return {
|
||||
listSchedules: vi.fn().mockResolvedValue([]),
|
||||
createSchedule: vi.fn(),
|
||||
updateSchedule: vi.fn(),
|
||||
duplicateSchedule: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
removeSchedule: vi.fn(),
|
||||
previewCron: vi.fn().mockResolvedValue([]),
|
||||
runNow: vi.fn(),
|
||||
listRuns: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('React AppShell and Fleet vertical slice', () => {
|
||||
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
@@ -84,7 +99,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
|
||||
const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ });
|
||||
expect(
|
||||
within(alpha).getByRole('link', { name: /打开 Alpha 实例仪表盘/ }).getAttribute('href'),
|
||||
within(alpha)
|
||||
.getByRole('link', { name: /打开 Alpha 实例仪表盘/ })
|
||||
.getAttribute('href'),
|
||||
).toBe('/instances/alpha/overview');
|
||||
expect(
|
||||
within(screen.getByRole('article', { name: /Bravo 实例概览/ })).getByText(
|
||||
@@ -101,6 +118,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
});
|
||||
|
||||
it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fleetMessagesDataSource: FleetMessagesDataSource = {
|
||||
load: vi.fn(async (instanceId) =>
|
||||
instanceId === 'bravo'
|
||||
@@ -131,11 +149,11 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
||||
expect(within(card).queryByText('延迟')).toBeNull();
|
||||
expect(within(card).queryByText('版本')).toBeNull();
|
||||
expect(within(card).queryByText('新鲜度')).toBeNull();
|
||||
expect(within(card).queryByText('40 ms')).toBeNull();
|
||||
expect(within(card).queryByText('2.0')).toBeNull();
|
||||
expect(within(card).getByText('SimAdmin 2.0')).toBeTruthy();
|
||||
expect(within(card).queryByText('可能过期')).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
expect(within(card).getByText('18.4%')).toBeTruthy();
|
||||
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||
@@ -152,9 +170,14 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
|
||||
expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
|
||||
expect(within(card).queryByText('bravo')).toBeNull();
|
||||
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy();
|
||||
expect(within(card).queryByRole('group', { name: '实例运维操作' })).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).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
|
||||
expect(within(card).getByRole('menuitem', { name: '系统重启 Bravo' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps message read failures separate from instance reachability and reports each once', async () => {
|
||||
@@ -171,34 +194,64 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(bravo).getByText('需要认证')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps Jobs and Audit routes compatible without left nav, settings in top bar only', async () => {
|
||||
it('keeps every global workspace reachable and exposes settings subsections', async () => {
|
||||
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||
const scheduleDataSource: ScheduleDataSource = {
|
||||
listSchedules: vi.fn().mockResolvedValue([]),
|
||||
createSchedule: vi.fn(),
|
||||
updateSchedule: vi.fn(),
|
||||
duplicateSchedule: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
removeSchedule: vi.fn(),
|
||||
previewCron: vi.fn().mockResolvedValue([]),
|
||||
runNow: vi.fn(),
|
||||
listRuns: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<AppShell
|
||||
pathname="/jobs"
|
||||
jobsDataSource={jobsDataSource}
|
||||
scheduleDataSource={scheduleDataSource}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
|
||||
expect(screen.queryByRole('navigation', { name: '全局导航' })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: '任务' })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: '审计' })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: '实例总览' })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
|
||||
'/settings/system',
|
||||
const navigation = screen.getByRole('navigation', { name: '全局导航' });
|
||||
expect(within(navigation).getByRole('link', { name: '节点' }).getAttribute('href')).toBe(
|
||||
'/fleet',
|
||||
);
|
||||
expect(within(navigation).getByRole('link', { name: '自动化' }).getAttribute('href')).toBe(
|
||||
'/automation',
|
||||
);
|
||||
expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
|
||||
'/settings/instances',
|
||||
);
|
||||
expect(
|
||||
within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'),
|
||||
).toBe('page');
|
||||
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
|
||||
expect(within(navigation).getAllByRole('link')).toHaveLength(3);
|
||||
|
||||
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||
rerender(
|
||||
<AppShell
|
||||
pathname="/audit"
|
||||
auditDataSource={auditDataSource}
|
||||
scheduleDataSource={scheduleDataSource}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
|
||||
|
||||
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
|
||||
const settingsNavigation = screen.getByRole('navigation', { name: '设置导航' });
|
||||
expect(within(settingsNavigation).getByRole('link', { name: '实例管理' })).toBeTruthy();
|
||||
expect(
|
||||
within(settingsNavigation)
|
||||
.getByRole('link', { name: '系统与安全' })
|
||||
.getAttribute('aria-current'),
|
||||
).toBe('page');
|
||||
});
|
||||
|
||||
it('hides placeholder dev version badge in the top bar', () => {
|
||||
@@ -229,7 +282,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
<AppShell pathname="/instances/bravo/overview" instanceDataSource={instanceDataSource} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
|
||||
'正在加载实例',
|
||||
);
|
||||
expect(await screen.findByText('Bravo')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
||||
@@ -283,6 +338,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
await user.click(screen.getByRole('button', { name: /需认证/ }));
|
||||
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
|
||||
expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
await user.click(screen.getByRole('button', { name: '全选本页' }));
|
||||
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
@@ -333,11 +389,12 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
|
||||
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '批量操作' }));
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '批量重启服务' }) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
await user.click(screen.getByRole('button', { name: '批量重启服务' }));
|
||||
expect(screen.getByText('已选择 1 项。重启将逐个实例安全确认后执行。')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '批量重启服务' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '批量系统重启' })).toBeTruthy();
|
||||
@@ -357,6 +414,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
|
||||
expect(screen.queryByRole('article', { name: /Instance 11 实例概览/ })).toBeNull();
|
||||
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '全选本页' }));
|
||||
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
@@ -383,7 +441,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
|
||||
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
|
||||
'正在加载实例',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -408,7 +468,13 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
);
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
|
||||
render(<AppShell pathname={pathname} eventStreamClient={quietEventStreamClient} />);
|
||||
render(
|
||||
<AppShell
|
||||
pathname={pathname}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
scheduleDataSource={emptyScheduleDataSource()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(emptyMessage)).toBeTruthy();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
@@ -446,6 +512,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
<AppShell
|
||||
pathname={pathname}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
scheduleDataSource={emptyScheduleDataSource()}
|
||||
{...(sourceProp === 'jobsDataSource'
|
||||
? { jobsDataSource: injectedSource as JobsDataSource }
|
||||
: { auditDataSource: injectedSource as AuditDataSource })}
|
||||
|
||||
+131
-30
@@ -3,6 +3,13 @@ import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
||||
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Tag } from 'animal-island-ui';
|
||||
import { Icon } from './ui/icon.js';
|
||||
import {
|
||||
AutomationPage,
|
||||
type AutomationDataSource as ScheduleDataSource,
|
||||
} from './automation/automation-page.js';
|
||||
import { createAutomationApiDataSource } from './automation/automation-api-data-source.js';
|
||||
|
||||
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
|
||||
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
|
||||
@@ -41,7 +48,7 @@ import {
|
||||
type InstanceCapabilityMap,
|
||||
} from './instances/instance-detail.js';
|
||||
|
||||
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
|
||||
export type GlobalSection = 'fleet' | 'automation' | 'settings';
|
||||
export type InstanceModule =
|
||||
| 'overview'
|
||||
| 'cellular'
|
||||
@@ -55,6 +62,7 @@ export type InstanceModule =
|
||||
export type RouteKind =
|
||||
| 'redirect'
|
||||
| 'fleet'
|
||||
| 'automation'
|
||||
| 'instance-new'
|
||||
| `instance-${InstanceModule}`
|
||||
| 'jobs'
|
||||
@@ -109,6 +117,7 @@ export interface AppShellProps {
|
||||
otaDataSource?: OtaDataSource;
|
||||
jobsDataSource?: JobsDataSource;
|
||||
auditDataSource?: AuditDataSource;
|
||||
scheduleDataSource?: ScheduleDataSource;
|
||||
eventStreamClient?: EventStreamClient;
|
||||
}
|
||||
|
||||
@@ -132,6 +141,7 @@ export function resolveRoute(input: string): ResolvedRoute {
|
||||
if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' };
|
||||
const staticRoutes: Readonly<Record<string, RouteKind>> = {
|
||||
'/fleet': 'fleet',
|
||||
'/automation': 'automation',
|
||||
'/instances/new': 'instance-new',
|
||||
'/jobs': 'jobs',
|
||||
'/audit': 'audit',
|
||||
@@ -162,8 +172,8 @@ export function resolveRoute(input: string): ResolvedRoute {
|
||||
function section(route: ResolvedRoute): GlobalSection | undefined {
|
||||
if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-'))
|
||||
return 'fleet';
|
||||
if (route.kind.startsWith('job')) return 'jobs';
|
||||
if (route.kind.startsWith('audit')) return 'audit';
|
||||
if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit'))
|
||||
return 'automation';
|
||||
if (route.kind.startsWith('settings')) return 'settings';
|
||||
return undefined;
|
||||
}
|
||||
@@ -188,6 +198,7 @@ function Page({
|
||||
otaDataSource,
|
||||
jobsDataSource,
|
||||
auditDataSource,
|
||||
scheduleDataSource,
|
||||
fleetRefreshSignal,
|
||||
detailRefreshSignal,
|
||||
}: {
|
||||
@@ -210,6 +221,7 @@ function Page({
|
||||
otaDataSource: OtaDataSource | undefined;
|
||||
jobsDataSource: JobsDataSource | undefined;
|
||||
auditDataSource: AuditDataSource | undefined;
|
||||
scheduleDataSource: ScheduleDataSource;
|
||||
fleetRefreshSignal: number;
|
||||
detailRefreshSignal: number;
|
||||
}): ReactNode {
|
||||
@@ -237,18 +249,25 @@ function Page({
|
||||
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'jobs')
|
||||
if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit')
|
||||
return (
|
||||
<JobsPage
|
||||
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'audit')
|
||||
return (
|
||||
<AuditPage
|
||||
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
<AutomationPage
|
||||
dataSource={scheduleDataSource}
|
||||
initialTab={
|
||||
route.kind === 'jobs' ? 'runs' : route.kind === 'audit' ? 'records' : 'schedules'
|
||||
}
|
||||
runsContent={
|
||||
<JobsPage
|
||||
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
}
|
||||
recordsContent={
|
||||
<AuditPage
|
||||
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'settings-instances')
|
||||
@@ -405,6 +424,33 @@ function displayConsoleVersion(version: string | undefined): string | undefined
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const GLOBAL_NAVIGATION: readonly {
|
||||
section: GlobalSection;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: 'grid' | 'jobs' | 'settings';
|
||||
}[] = [
|
||||
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
|
||||
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
|
||||
{ section: 'settings', href: '/settings/instances', label: '设置', icon: 'settings' },
|
||||
];
|
||||
|
||||
const STREAM_LABELS = {
|
||||
connecting: '正在连接',
|
||||
open: '实时连接正常',
|
||||
reconnecting: '正在重新连接',
|
||||
resetting: '正在同步状态',
|
||||
closed: '实时连接已关闭',
|
||||
} as const;
|
||||
|
||||
const STREAM_COLORS = {
|
||||
connecting: 'app-yellow',
|
||||
open: 'app-teal',
|
||||
reconnecting: 'app-orange',
|
||||
resetting: 'app-yellow',
|
||||
closed: 'app-red',
|
||||
} as const;
|
||||
|
||||
export function AppShell({
|
||||
pathname,
|
||||
version = 'dev',
|
||||
@@ -426,6 +472,7 @@ export function AppShell({
|
||||
otaDataSource,
|
||||
jobsDataSource,
|
||||
auditDataSource,
|
||||
scheduleDataSource,
|
||||
eventStreamClient,
|
||||
}: AppShellProps) {
|
||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||
@@ -441,6 +488,10 @@ export function AppShell({
|
||||
() => auditDataSource ?? createAuditApiDataSource(),
|
||||
[auditDataSource],
|
||||
);
|
||||
const resolvedScheduleDataSource = useMemo(
|
||||
() => scheduleDataSource ?? createAutomationApiDataSource(),
|
||||
[scheduleDataSource],
|
||||
);
|
||||
const resolved = resolveRoute(pathname);
|
||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
||||
const routeInstanceId = route.params?.instanceId;
|
||||
@@ -598,29 +649,71 @@ export function AppShell({
|
||||
跳到主要内容
|
||||
</a>
|
||||
<header className="app-topbar">
|
||||
<a className="product-name" href="/fleet">
|
||||
多实例 SimAdmin 管理台
|
||||
</a>
|
||||
<div className="topbar-actions">
|
||||
<span className="connection-status">控制台</span>
|
||||
{consoleVersion ? (
|
||||
<span className="version-badge" aria-label={`控制台版本 ${consoleVersion}`}>
|
||||
v{consoleVersion}
|
||||
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
|
||||
<span className="product-mark" aria-hidden="true">
|
||||
<span className="product-signal">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
) : null}
|
||||
<a
|
||||
className="topbar-settings"
|
||||
href="/settings/system"
|
||||
aria-current={currentSection === 'settings' ? 'page' : undefined}
|
||||
</span>
|
||||
<span className="product-copy">
|
||||
<strong>SimAdmin Control</strong>
|
||||
<small>多节点蜂窝设备控制中心</small>
|
||||
</span>
|
||||
</a>
|
||||
<nav className="global-navigation" aria-label="全局导航">
|
||||
{GLOBAL_NAVIGATION.map((item) => (
|
||||
<a
|
||||
key={item.section}
|
||||
href={item.href}
|
||||
aria-current={currentSection === item.section ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="topbar-actions">
|
||||
<Tag
|
||||
className="connection-status"
|
||||
color={STREAM_COLORS[refresh.stream]}
|
||||
variant="soft"
|
||||
size="small"
|
||||
>
|
||||
设置
|
||||
</a>
|
||||
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
|
||||
{STREAM_LABELS[refresh.stream]}
|
||||
</span>
|
||||
</Tag>
|
||||
{consoleVersion ? (
|
||||
<Tag className="version-badge" color="brown" variant="soft" size="small">
|
||||
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="app-layout app-layout-single">
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{currentSection === 'settings' ? (
|
||||
<nav className="settings-navigation" aria-label="设置导航">
|
||||
<a
|
||||
href="/settings/instances"
|
||||
aria-current={route.kind === 'settings-system' ? undefined : 'page'}
|
||||
>
|
||||
实例管理
|
||||
</a>
|
||||
<a
|
||||
href="/settings/system"
|
||||
aria-current={route.kind === 'settings-system' ? 'page' : undefined}
|
||||
>
|
||||
系统与安全
|
||||
</a>
|
||||
</nav>
|
||||
) : null}
|
||||
{routeInstanceId && instanceLoading ? (
|
||||
<p role="status">正在加载实例…</p>
|
||||
<p role="status" aria-label="实例加载状态">
|
||||
正在加载实例…
|
||||
</p>
|
||||
) : routeInstanceId && instanceLoadFailed ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
无法加载此实例,请返回总览后重试。
|
||||
@@ -646,12 +739,20 @@ export function AppShell({
|
||||
otaDataSource={otaDataSource}
|
||||
jobsDataSource={resolvedJobsDataSource}
|
||||
auditDataSource={resolvedAuditDataSource}
|
||||
scheduleDataSource={resolvedScheduleDataSource}
|
||||
fleetRefreshSignal={refresh.fleet}
|
||||
detailRefreshSignal={refresh.detail}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<footer className="app-footer" aria-label="项目与组件库信息">
|
||||
<p>
|
||||
SimAdmin 聚合控制台 · 界面组件来自{' '}
|
||||
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
||||
(CC BY-NC 4.0,仅限非商业使用)
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<svg t="1777874742854" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13571" width="200" height="200"><path d="M512 57.677643C226.27383 57.677643 57.677643 226.27383 57.677643 512s168.596187 454.322357 454.322357 454.322357 454.322357-168.596187 454.322357-454.322357S797.72617 57.677643 512 57.677643z" fill="#1296db" p-id="13572" data-spm-anchor-id="a313x.search_index.0.i24.43193a81ZcyisA" class="selected"></path><path d="M286.64635 708.389712c0 11.801733 4.702946 23.071057 12.955286 31.412132s19.610399 12.955286 31.412132 12.955286h361.949393c24.490815 0 44.367418-19.876603 44.367418-44.367418v-308.797227l-122.010399-128.310572h-284.306412c-11.801733 0-23.071057 4.702946-31.412132 12.955286s-12.955286 19.610399-12.955286 31.412132v392.740381z" fill="#ffffff" p-id="13573" data-spm-anchor-id="a313x.search_index.0.i23.43193a81ZcyisA" class=""></path><path d="M692.963161 766.067355h-361.949393c-15.439861 0-29.90364-6.033969-40.818024-16.859618s-16.859619-25.378163-16.859619-40.818025v-392.740381c0-15.439861 6.033969-29.90364 16.859619-40.818024 10.914385-10.82565 25.378163-16.859619 40.818024-16.859619h289.985442l129.641594 136.296707v314.121317c0 31.767071-25.821837 57.677643-57.677643 57.677643z m-361.949393-481.475216c-8.25234 0-16.061005 3.194454-21.917505 9.139688-5.945234 5.856499-9.139688 13.665165-9.139688 21.917504v392.740381c0 8.25234 3.194454 16.061005 9.139688 21.917505 5.856499 5.856499 13.665165 9.139688 21.917505 9.139688h361.949393c17.125823 0 31.057192-13.931369 31.057193-31.057193v-303.473137l-114.467938-120.324436h-278.538648z" fill="#FFFFFF" p-id="13574"></path><path d="M464.64843 699.338759l0.532409-89.444714-50.401387-16.14974-50.401386 16.14974v45.077296c0 11.801733 4.702946 23.071057 12.955286 31.412132s19.610399 12.955286 31.412132 12.955286h55.902946z m97.519584 0l0.266204-89.444714-48.626689-16.14974-48.62669 16.14974-0.532409 89.444714h97.519584z m48.804159-152.801386l-48.183015-14.641248-48.981629 14.641248-47.739342-14.641248v-77.99792h-57.322703c-11.801733 0-23.071057 4.702946-31.412132 12.955285-8.341075 8.341075-12.955286 19.610399-12.955286 31.412132v111.539688l50.401386-16.14974 50.401387 16.14974 48.62669-16.14974 48.626689 16.14974 48.62669-16.14974 48.62669 16.14974v-77.99792l-48.715425 14.729983z m48.62669-48.271751c0-11.801733-4.702946-23.071057-12.955286-31.412132-8.341075-8.341075-19.610399-12.955286-31.412132-12.955285h-52.708492l0.35494 77.99792 48.183015 14.641248 48.62669-14.641248-0.088735-33.630503z m-97.430849 201.073137h53.152166c24.490815 0 44.367418-19.876603 44.367418-44.367418v-45.077296l-48.62669-16.14974-48.62669 16.14974-0.266204 89.444714z m0.354939-245.440554h-96.454766v77.99792l47.739342 14.641248 49.070364-14.641248-0.35494-77.99792z" fill="#E1B460" p-id="13575"></path><path d="M557.731272 703.775501h-148.985788c-13.044021 0-25.289428-5.057886-34.517851-14.286309s-14.286308-21.562565-14.286309-34.517851v-156.705719c0-13.044021 5.057886-25.289428 14.286309-34.517851 9.228423-9.228423 21.47383-14.286308 34.517851-14.286308h206.574696c13.044021 0 25.289428 5.057886 34.517851 14.286308s14.286308 21.47383 14.286309 34.517851v156.616985c0 26.886655-21.917504 48.804159-48.80416 48.804159l-57.588908 0.088735z m-88.557366-8.873484h88.557366l0.266205-81.813518-44.189948-14.729983-44.189948 14.641248-0.443675 81.902253z m-100.359098-81.813518v41.882842c0 10.64818 4.170537 20.675217 11.712998 28.217678s17.569497 11.712998 28.217678 11.712998h51.554939l0.443674-81.813518-45.964645-14.729983-45.964644 14.729983z m198.056152 0l-0.266204 81.813518h48.626689c22.006239 0 39.930676-17.924437 39.930676-39.930676v-41.882842l-44.189948-14.641248-44.101213 14.641248z m-152.091508-24.04714l50.401387 16.14974 48.62669-16.14974 48.626689 16.14974 48.62669-16.14974 44.189948 14.641248v-65.841248l-44.189948 13.310225-48.183015-14.641248-49.070364 14.641248-48.360486-14.818717h-0.177469v-0.088735l-3.549394-1.064818v-76.844368h-52.885962c-10.64818 0-20.675217 4.170537-28.217677 11.712999s-11.712998 17.569497-11.712999 28.217677v105.505719l45.87591-14.729982z m152.535182-60.428423l43.746274 13.310225 44.189948-13.310225v-30.258579c0-10.64818-4.170537-20.675217-11.712998-28.217678s-17.569497-11.712998-28.217678-11.712998h-48.27175l0.266204 70.189255z m-96.809705 0l43.3026 13.310225 44.633622-13.310225-0.266205-70.189255h-87.581282v70.189255z" fill="#666666" p-id="13576"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from 'animal-island-ui';
|
||||
|
||||
import {
|
||||
AUDIT_OUTCOMES,
|
||||
@@ -287,9 +288,9 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
|
||||
<h1 id="audit-title">审计</h1>
|
||||
<p>只读运维审计事件。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
<Button htmlType="button" size="small" onClick={() => setAttempt((value) => value + 1)}>
|
||||
刷新审计事件
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="fleet-toolbar">
|
||||
{identifiers.map(([field, label]) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { Button, Card, Title } from 'animal-island-ui';
|
||||
|
||||
import type { ConsoleAuthDataSource, ConsoleAuthStatus } from './console-auth.js';
|
||||
import { createConsoleAuthApiDataSource } from './console-auth.js';
|
||||
@@ -70,66 +71,70 @@ export function ConsoleAuthSettings({
|
||||
<section className="settings-page" aria-labelledby="password-protection-title">
|
||||
<header className="page-heading">
|
||||
<p className="eyebrow">SYSTEM SETTINGS</p>
|
||||
<h1 id="password-protection-title">密码保护</h1>
|
||||
<h1 id="password-protection-title">
|
||||
<Title color="app-green">密码保护</Title>
|
||||
</h1>
|
||||
<p>可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。</p>
|
||||
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
||||
</header>
|
||||
{error && !status ? <p role="alert">{error}</p> : null}
|
||||
{status ? (
|
||||
<form className="settings-card auth-settings" onSubmit={(event) => void save(event)}>
|
||||
<label className="toggle-row">
|
||||
<span>
|
||||
<strong>启用密码保护</strong>
|
||||
<small>启用后,访问实例、短信和设置前都需要先登录。</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => {
|
||||
setEnabled(event.target.checked);
|
||||
setNotice('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{enabled && !status.configured ? (
|
||||
<div className="auth-password-fields">
|
||||
<label htmlFor="new-console-password">设置访问密码</label>
|
||||
<Card pattern="default" className="settings-card">
|
||||
<form className="auth-settings" onSubmit={(event) => void save(event)}>
|
||||
<label className="toggle-row">
|
||||
<span>
|
||||
<strong>启用密码保护</strong>
|
||||
<small>启用后,访问实例、短信和设置前都需要先登录。</small>
|
||||
</span>
|
||||
<input
|
||||
id="new-console-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => {
|
||||
setEnabled(event.target.checked);
|
||||
setNotice('');
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="confirm-console-password">确认访问密码</label>
|
||||
<input
|
||||
id="confirm-console-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
/>
|
||||
<small>至少 8 位,同时包含字母和数字。密码仅保存为不可逆哈希。</small>
|
||||
</div>
|
||||
) : null}
|
||||
{status.configured ? <p>访问密码已配置,不会在页面或 API 中回显。</p> : null}
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{notice ? <p role="status">{notice}</p> : null}
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? '正在保存…' : '保存密码保护设置'}
|
||||
</button>
|
||||
{status.protectionEnabled && status.authenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void source.logout().then(() => window.location.reload());
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</label>
|
||||
{enabled && !status.configured ? (
|
||||
<div className="auth-password-fields">
|
||||
<label htmlFor="new-console-password">设置访问密码</label>
|
||||
<input
|
||||
id="new-console-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<label htmlFor="confirm-console-password">确认访问密码</label>
|
||||
<input
|
||||
id="confirm-console-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
/>
|
||||
<small>至少 8 位,同时包含字母和数字。密码仅保存为不可逆哈希。</small>
|
||||
</div>
|
||||
) : null}
|
||||
{status.configured ? <p>访问密码已配置,不会在页面或 API 中回显。</p> : null}
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{notice ? <p role="status">{notice}</p> : null}
|
||||
<Button htmlType="submit" type="primary" disabled={saving} loading={saving}>
|
||||
{saving ? '正在保存…' : '保存密码保护设置'}
|
||||
</Button>
|
||||
{status.protectionEnabled && status.authenticated ? (
|
||||
<Button
|
||||
htmlType="button"
|
||||
onClick={() => {
|
||||
void source.logout().then(() => window.location.reload());
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
) : !error ? (
|
||||
<p role="status">正在读取设置…</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { CreateScheduledTaskRequest, ScheduledTask } from '@multi-simadmin/contracts';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAutomationApiDataSource } from './automation-api-data-source.js';
|
||||
|
||||
const input: CreateScheduledTaskRequest = {
|
||||
name: 'Morning restart',
|
||||
operationType: 'restart-service',
|
||||
cronExpression: '0 9 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
targetSelector: { mode: 'fixed', instanceIds: ['alpha'] },
|
||||
misfirePolicy: 'skip',
|
||||
overlapPolicy: 'skip',
|
||||
retryPolicy: { maxRetries: 1, retryIntervalSeconds: 30 },
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const task: ScheduledTask = {
|
||||
...input,
|
||||
id: 'schedule/a',
|
||||
version: 3,
|
||||
createdBy: 'operator',
|
||||
updatedBy: 'operator',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('Automation API data source', () => {
|
||||
it('uses the authenticated schedule endpoints and optimistic version headers', async () => {
|
||||
const fetcher = vi.fn(async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(request);
|
||||
if (url.endsWith('/cron/preview')) {
|
||||
return Response.json({ occurrences: ['2026-07-30T01:00:00.000Z'] });
|
||||
}
|
||||
if (url.endsWith('/runs')) return Response.json({ items: [] });
|
||||
if (init?.method === 'DELETE') return new Response(null, { status: 204 });
|
||||
if (url.endsWith('/run')) return Response.json({ runId: 'run-1' });
|
||||
if (url.endsWith('/schedules') && !init?.method) return Response.json({ items: [task] });
|
||||
return Response.json(task);
|
||||
});
|
||||
const source = createAutomationApiDataSource(fetcher as typeof fetch);
|
||||
const controller = new AbortController();
|
||||
|
||||
await expect(source.listSchedules(controller.signal)).resolves.toEqual([task]);
|
||||
await expect(source.createSchedule(input)).resolves.toEqual(task);
|
||||
await expect(
|
||||
source.updateSchedule(task.id, task.version, { name: 'Updated' }),
|
||||
).resolves.toEqual(task);
|
||||
await expect(source.duplicateSchedule(task.id, task.version)).resolves.toEqual(task);
|
||||
await expect(source.setEnabled(task.id, task.version, false)).resolves.toEqual(task);
|
||||
await expect(source.previewCron(input.cronExpression)).resolves.toEqual([
|
||||
'2026-07-30T01:00:00.000Z',
|
||||
]);
|
||||
await expect(source.runNow(task.id)).resolves.toEqual({ runId: 'run-1' });
|
||||
await expect(source.listRuns(controller.signal)).resolves.toEqual([]);
|
||||
await expect(source.removeSchedule(task.id, task.version)).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/state', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'if-match': '"version-3"',
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'if-match': '"version-3"',
|
||||
},
|
||||
body: JSON.stringify({ name: 'Updated' }),
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/duplicate', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'if-match': '"version-3"' },
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', {
|
||||
method: 'DELETE',
|
||||
headers: { 'if-match': '"version-3"' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-success responses without exposing response bodies', async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ detail: 'secret backend detail' }, { status: 500 }),
|
||||
);
|
||||
const source = createAutomationApiDataSource(fetcher as typeof fetch);
|
||||
|
||||
await expect(source.listSchedules()).rejects.toThrow('Automation request failed (500)');
|
||||
await expect(source.listSchedules()).rejects.not.toThrow('secret backend detail');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CreateScheduledTaskRequest,
|
||||
ScheduledRun,
|
||||
ScheduledTask,
|
||||
} from '@multi-simadmin/contracts';
|
||||
|
||||
import type { AutomationDataSource } from './automation-page.js';
|
||||
|
||||
async function json<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function createAutomationApiDataSource(fetcher: typeof fetch = fetch): AutomationDataSource {
|
||||
return {
|
||||
async listSchedules(signal) {
|
||||
const page = await json<{ items: ScheduledTask[] }>(
|
||||
await fetcher('/api/v1/automation/schedules', {
|
||||
...(signal ? { signal } : {}),
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
return page.items;
|
||||
},
|
||||
async createSchedule(input) {
|
||||
return json<ScheduledTask>(
|
||||
await fetcher('/api/v1/automation/schedules', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input satisfies CreateScheduledTaskRequest),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async updateSchedule(id, version, input) {
|
||||
return json<ScheduledTask>(
|
||||
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'if-match': `"version-${version}"`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async duplicateSchedule(id, version) {
|
||||
return json<ScheduledTask>(
|
||||
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'if-match': `"version-${version}"` },
|
||||
}),
|
||||
);
|
||||
},
|
||||
async setEnabled(id, version, enabled) {
|
||||
return json<ScheduledTask>(
|
||||
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/state`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'if-match': `"version-${version}"`,
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async removeSchedule(id, version) {
|
||||
const response = await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'if-match': `"version-${version}"` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
|
||||
},
|
||||
async previewCron(cronExpression) {
|
||||
const preview = await json<{ occurrences: string[] }>(
|
||||
await fetcher('/api/v1/automation/cron/preview', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cronExpression, count: 5 }),
|
||||
}),
|
||||
);
|
||||
return preview.occurrences;
|
||||
},
|
||||
async runNow(id) {
|
||||
return json<unknown>(
|
||||
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/run`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
},
|
||||
async listRuns(signal) {
|
||||
const page = await json<{ items: ScheduledRun[] }>(
|
||||
await fetcher('/api/v1/automation/runs', {
|
||||
...(signal ? { signal } : {}),
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
return page.items;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutomationPage, type AutomationDataSource } from './automation-page.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function source(): AutomationDataSource {
|
||||
return {
|
||||
listSchedules: async () => [],
|
||||
createSchedule: async (input) => ({
|
||||
id: 'task-1',
|
||||
version: 1,
|
||||
createdBy: 'operator',
|
||||
updatedBy: 'operator',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
nextDueAt: '2026-07-30T01:00:00.000Z',
|
||||
...input,
|
||||
}),
|
||||
updateSchedule: async () => undefined as never,
|
||||
duplicateSchedule: async () => undefined as never,
|
||||
setEnabled: async () => undefined,
|
||||
removeSchedule: async () => undefined,
|
||||
previewCron: async () => [
|
||||
'2026-07-30T01:00:00.000Z',
|
||||
'2026-07-31T01:00:00.000Z',
|
||||
'2026-08-01T01:00:00.000Z',
|
||||
'2026-08-02T01:00:00.000Z',
|
||||
'2026-08-03T01:00:00.000Z',
|
||||
],
|
||||
runNow: async () => undefined,
|
||||
listRuns: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AutomationPage', () => {
|
||||
it('presents schedules, runs, and operation records as one Chinese workspace', async () => {
|
||||
render(<AutomationPage dataSource={source()} />);
|
||||
const tabs = screen.getByRole('tablist', { name: '自动化视图' });
|
||||
expect(within(tabs).getByRole('tab', { name: '计划任务' }).getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
|
||||
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
|
||||
expect(await screen.findByText('暂无计划任务')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens a progressive editor with dynamic tags and no timezone selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage dataSource={source()} />);
|
||||
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
|
||||
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
|
||||
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
|
||||
expect(within(dialog).getByLabelText('标签')).not.toBeNull();
|
||||
expect(within(dialog).queryByLabelText('时区')).toBeNull();
|
||||
expect(within(dialog).getByText(/北京时间/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes the editor with Escape and restores focus to its trigger', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage dataSource={source()} />);
|
||||
const trigger = screen.getByRole('button', { name: '创建任务' });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole('dialog', { name: '创建任务' })).not.toBeNull();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '创建任务' })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('shows SMS fields and previews five-field Cron occurrences', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dataSource = source();
|
||||
render(<AutomationPage dataSource={dataSource} />);
|
||||
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
||||
await user.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms');
|
||||
expect(within(dialog).getByLabelText('收件号码')).not.toBeNull();
|
||||
expect(within(dialog).getByLabelText('短信内容')).not.toBeNull();
|
||||
await user.click(within(dialog).getByRole('button', { name: '预览后续执行' }));
|
||||
expect(await within(dialog).findByText(/2026-07-30 09:00/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('requires a final operation, target, and risk confirmation before creating', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createSchedule = vi.fn(source().createSchedule);
|
||||
render(<AutomationPage dataSource={{ ...source(), createSchedule }} />);
|
||||
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
||||
await user.type(within(dialog).getByLabelText('任务名称'), 'Night restart');
|
||||
await user.type(within(dialog).getByLabelText('实例 ID'), 'alpha, beta');
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
|
||||
|
||||
expect(createSchedule).not.toHaveBeenCalled();
|
||||
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
|
||||
expect(within(confirmation).getByText('重启 SimAdmin 服务')).not.toBeNull();
|
||||
expect(within(confirmation).getByText('2 个固定实例')).not.toBeNull();
|
||||
expect(within(confirmation).getByText('R2')).not.toBeNull();
|
||||
await user.click(within(confirmation).getByRole('checkbox', { name: /我已核对/ }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
|
||||
expect(createSchedule).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('edits effective windows and duplicates an existing schedule', async () => {
|
||||
const user = userEvent.setup();
|
||||
const existing = {
|
||||
id: 'task-1',
|
||||
name: 'Morning restart',
|
||||
operationType: 'restart-service' as const,
|
||||
cronExpression: '0 9 * * *',
|
||||
timezone: 'Asia/Shanghai' as const,
|
||||
targetSelector: { mode: 'fixed' as const, instanceIds: ['alpha', 'beta'] },
|
||||
effectiveStartAt: '2026-08-01T01:00:00.000Z',
|
||||
effectiveEndAt: '2026-09-01T01:00:00.000Z',
|
||||
misfirePolicy: 'skip' as const,
|
||||
overlapPolicy: 'skip' as const,
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
|
||||
enabled: true,
|
||||
version: 4,
|
||||
createdBy: 'operator',
|
||||
updatedBy: 'operator',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
};
|
||||
const updateSchedule = vi.fn(async (_id, _version, input) => ({
|
||||
...existing,
|
||||
...input,
|
||||
version: 5,
|
||||
}));
|
||||
const duplicateSchedule = vi.fn(async () => ({
|
||||
...existing,
|
||||
id: 'task-2',
|
||||
name: 'Morning restart copy',
|
||||
enabled: false,
|
||||
version: 1,
|
||||
}));
|
||||
const dataSource: AutomationDataSource = {
|
||||
...source(),
|
||||
listSchedules: async () => [existing],
|
||||
updateSchedule,
|
||||
duplicateSchedule,
|
||||
};
|
||||
render(<AutomationPage dataSource={dataSource} />);
|
||||
|
||||
await screen.findByText('Morning restart');
|
||||
expect(screen.queryByRole('menuitem', { name: '编辑 Morning restart' })).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: '任务操作 Morning restart' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: '编辑 Morning restart' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '编辑任务' });
|
||||
expect(within(dialog).getByLabelText('生效开始(可选)')).not.toBeNull();
|
||||
expect(within(dialog).getByLabelText('生效结束(可选)')).not.toBeNull();
|
||||
const name = within(dialog).getByLabelText('任务名称');
|
||||
await user.clear(name);
|
||||
await user.type(name, 'Updated restart');
|
||||
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
|
||||
await user.click(within(dialog).getByRole('checkbox', { name: /我已核对/ }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认并保存' }));
|
||||
|
||||
expect(updateSchedule).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
4,
|
||||
expect.objectContaining({
|
||||
name: 'Updated restart',
|
||||
effectiveStartAt: '2026-08-01T01:00:00.000Z',
|
||||
effectiveEndAt: '2026-09-01T01:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '任务操作 Updated restart' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: '复制 Updated restart' }));
|
||||
expect(duplicateSchedule).toHaveBeenCalledWith('task-1', 5);
|
||||
expect(await screen.findByText('Morning restart copy')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
import type {
|
||||
CreateScheduledTaskRequest,
|
||||
ScheduledRun,
|
||||
ScheduledTask,
|
||||
UpdateScheduledTaskRequest,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { Icon } from '../ui/icon.js';
|
||||
|
||||
export type AutomationTab = 'schedules' | 'runs' | 'records';
|
||||
export type ScheduleUpdateInput = Omit<UpdateScheduledTaskRequest, 'version'>;
|
||||
|
||||
export interface AutomationDataSource {
|
||||
listSchedules(signal?: AbortSignal): Promise<readonly ScheduledTask[]>;
|
||||
createSchedule(input: CreateScheduledTaskRequest): Promise<ScheduledTask>;
|
||||
updateSchedule(id: string, version: number, input: ScheduleUpdateInput): Promise<ScheduledTask>;
|
||||
duplicateSchedule(id: string, version: number): Promise<ScheduledTask>;
|
||||
setEnabled(id: string, version: number, enabled: boolean): Promise<ScheduledTask | undefined>;
|
||||
removeSchedule(id: string, version: number): Promise<void>;
|
||||
previewCron(expression: string): Promise<readonly string[]>;
|
||||
runNow(id: string): Promise<unknown>;
|
||||
listRuns(signal?: AbortSignal): Promise<readonly ScheduledRun[]>;
|
||||
}
|
||||
|
||||
export interface AutomationPageProps {
|
||||
readonly dataSource: AutomationDataSource;
|
||||
readonly initialTab?: AutomationTab;
|
||||
readonly runsContent?: ReactNode;
|
||||
readonly recordsContent?: ReactNode;
|
||||
}
|
||||
|
||||
function beijingTime(value: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date(value));
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
|
||||
}
|
||||
|
||||
function beijingLocal(value?: string): string {
|
||||
return value ? beijingTime(value).replace(' ', 'T') : '';
|
||||
}
|
||||
|
||||
function beijingIso(value: string): string | undefined {
|
||||
return value ? new Date(`${value}:00+08:00`).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function operationLabel(value: ScheduledTask['operationType']): string {
|
||||
return {
|
||||
'restart-service': '重启 SimAdmin 服务',
|
||||
'reboot-system': '重启设备系统',
|
||||
'send-sms': '发送短信',
|
||||
}[value];
|
||||
}
|
||||
|
||||
function targetLabel(task: ScheduledTask): string {
|
||||
return task.targetSelector.mode === 'fixed'
|
||||
? `${task.targetSelector.instanceIds.length} 个固定实例`
|
||||
: `${task.targetSelector.match === 'all' ? '全部匹配' : '任一匹配'}:${task.targetSelector.tags.join('、')}`;
|
||||
}
|
||||
|
||||
function outcomeLabel(value: ScheduledRun['outcome']): string {
|
||||
if (!value) return '执行中';
|
||||
return {
|
||||
succeeded: '成功',
|
||||
'partially-succeeded': '部分成功',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
'no-targets': '无匹配实例',
|
||||
'needs-attention': '需要处理',
|
||||
}[value];
|
||||
}
|
||||
|
||||
interface EditorForm {
|
||||
name: string;
|
||||
operationType: ScheduledTask['operationType'];
|
||||
targetMode: 'fixed' | 'tags';
|
||||
fixedIds: string;
|
||||
tags: string;
|
||||
tagMatch: 'any' | 'all';
|
||||
recipients: string;
|
||||
content: string;
|
||||
cronExpression: string;
|
||||
misfirePolicy: ScheduledTask['misfirePolicy'];
|
||||
overlapPolicy: ScheduledTask['overlapPolicy'];
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
effectiveStart: string;
|
||||
effectiveEnd: string;
|
||||
}
|
||||
|
||||
const defaults: EditorForm = {
|
||||
name: '',
|
||||
operationType: 'restart-service' as ScheduledTask['operationType'],
|
||||
targetMode: 'fixed' as 'fixed' | 'tags',
|
||||
fixedIds: '',
|
||||
tags: '',
|
||||
tagMatch: 'any' as 'any' | 'all',
|
||||
recipients: '',
|
||||
content: '',
|
||||
cronExpression: '0 9 * * *',
|
||||
misfirePolicy: 'skip',
|
||||
overlapPolicy: 'skip',
|
||||
maxRetries: 0,
|
||||
retryInterval: 60,
|
||||
effectiveStart: '',
|
||||
effectiveEnd: '',
|
||||
};
|
||||
|
||||
export function AutomationPage({
|
||||
dataSource,
|
||||
initialTab = 'schedules',
|
||||
runsContent,
|
||||
recordsContent,
|
||||
}: AutomationPageProps) {
|
||||
const [tab, setTab] = useState<AutomationTab>(initialTab);
|
||||
const [tasks, setTasks] = useState<readonly ScheduledTask[]>([]);
|
||||
const [runs, setRuns] = useState<readonly ScheduledRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<ScheduledTask>();
|
||||
const [form, setForm] = useState(defaults);
|
||||
const [preview, setPreview] = useState<readonly string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmedFrequency, setConfirmedFrequency] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
const [confirmedDefinition, setConfirmedDefinition] = useState(false);
|
||||
const [actionMenuId, setActionMenuId] = useState<string>();
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => setTab(initialTab), [initialTab]);
|
||||
|
||||
const load = () => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
void Promise.all([
|
||||
dataSource.listSchedules(controller.signal),
|
||||
dataSource.listRuns(controller.signal),
|
||||
]).then(
|
||||
([nextTasks, nextRuns]) => {
|
||||
setTasks(nextTasks);
|
||||
setRuns(nextRuns);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
setError('无法加载自动化数据,请稍后重试。');
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
};
|
||||
|
||||
useEffect(load, [dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorOpen) return;
|
||||
drawerRef.current?.querySelector<HTMLElement>('input, select, textarea, button')?.focus();
|
||||
}, [editorOpen]);
|
||||
|
||||
const highFrequencyRestart =
|
||||
form.operationType === 'restart-service' &&
|
||||
/^\*|^\*\/([1-5])(?:\s|$)/.test(form.cronExpression);
|
||||
|
||||
function field<K extends keyof typeof defaults>(key: K, value: (typeof defaults)[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
setReviewing(false);
|
||||
setConfirmedDefinition(false);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditorOpen(false);
|
||||
setEditingTask(undefined);
|
||||
setForm(defaults);
|
||||
setPreview([]);
|
||||
setConfirmedFrequency(false);
|
||||
setReviewing(false);
|
||||
setConfirmedDefinition(false);
|
||||
queueMicrotask(() => returnFocusRef.current?.focus());
|
||||
}
|
||||
|
||||
function createTask() {
|
||||
returnFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
setEditingTask(undefined);
|
||||
setForm(defaults);
|
||||
setPreview([]);
|
||||
setConfirmedFrequency(false);
|
||||
setReviewing(false);
|
||||
setConfirmedDefinition(false);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function editTask(task: ScheduledTask) {
|
||||
setEditingTask(task);
|
||||
setForm({
|
||||
name: task.name,
|
||||
operationType: task.operationType,
|
||||
targetMode: task.targetSelector.mode,
|
||||
fixedIds:
|
||||
task.targetSelector.mode === 'fixed' ? task.targetSelector.instanceIds.join(', ') : '',
|
||||
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
|
||||
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
|
||||
recipients: '',
|
||||
content: '',
|
||||
cronExpression: task.cronExpression,
|
||||
misfirePolicy: task.misfirePolicy,
|
||||
overlapPolicy: task.overlapPolicy,
|
||||
maxRetries: task.retryPolicy.maxRetries,
|
||||
retryInterval: task.retryPolicy.intervalSeconds,
|
||||
effectiveStart: beijingLocal(task.effectiveStartAt),
|
||||
effectiveEnd: beijingLocal(task.effectiveEndAt),
|
||||
});
|
||||
setPreview([]);
|
||||
setConfirmedFrequency(false);
|
||||
setReviewing(false);
|
||||
setConfirmedDefinition(false);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function handleEditorKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeEditor();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = drawerRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (!focusable?.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (highFrequencyRestart && (preview.length !== 5 || !confirmedFrequency)) return;
|
||||
const split = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const effectiveStartAt = beijingIso(form.effectiveStart);
|
||||
const effectiveEndAt = beijingIso(form.effectiveEnd);
|
||||
const smsChanged = form.recipients.trim().length > 0 || form.content.length > 0;
|
||||
const request: CreateScheduledTaskRequest = {
|
||||
name: form.name.trim(),
|
||||
operationType: form.operationType,
|
||||
cronExpression: form.cronExpression.trim(),
|
||||
timezone: 'Asia/Shanghai',
|
||||
targetSelector:
|
||||
form.targetMode === 'fixed'
|
||||
? { mode: 'fixed', instanceIds: split(form.fixedIds) }
|
||||
: { mode: 'tags', match: form.tagMatch, tags: split(form.tags) },
|
||||
...(form.operationType === 'send-sms' && (!editingTask?.sms || smsChanged)
|
||||
? { sms: { recipients: split(form.recipients), content: form.content } }
|
||||
: {}),
|
||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||
misfirePolicy: form.misfirePolicy,
|
||||
overlapPolicy: form.overlapPolicy,
|
||||
retryPolicy: { maxRetries: form.maxRetries, intervalSeconds: form.retryInterval },
|
||||
enabled: editingTask?.enabled ?? true,
|
||||
};
|
||||
if (!reviewing) {
|
||||
setReviewing(true);
|
||||
setConfirmedDefinition(false);
|
||||
return;
|
||||
}
|
||||
if (!confirmedDefinition) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editingTask) {
|
||||
const { sms: requestSms, ...common } = request;
|
||||
const updated = await dataSource.updateSchedule(editingTask.id, editingTask.version, {
|
||||
...common,
|
||||
...(requestSms ? { sms: requestSms } : {}),
|
||||
effectiveStartAt: effectiveStartAt ?? null,
|
||||
effectiveEndAt: effectiveEndAt ?? null,
|
||||
});
|
||||
setTasks((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||
} else {
|
||||
const created = await dataSource.createSchedule(request);
|
||||
setTasks((current) => [created, ...current]);
|
||||
}
|
||||
closeEditor();
|
||||
} catch {
|
||||
setError('任务保存失败,请检查表单后重试。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(task: ScheduledTask, enabled: boolean) {
|
||||
const updated = await dataSource.setEnabled(task.id, task.version, enabled);
|
||||
if (updated)
|
||||
setTasks((current) => current.map((item) => (item.id === task.id ? updated : item)));
|
||||
}
|
||||
|
||||
async function remove(task: ScheduledTask) {
|
||||
await dataSource.removeSchedule(task.id, task.version);
|
||||
setTasks((current) => current.filter((item) => item.id !== task.id));
|
||||
}
|
||||
|
||||
async function duplicate(task: ScheduledTask) {
|
||||
const created = await dataSource.duplicateSchedule(task.id, task.version);
|
||||
setTasks((current) => [created, ...current]);
|
||||
}
|
||||
|
||||
const tabs: readonly [AutomationTab, string][] = [
|
||||
['schedules', '计划任务'],
|
||||
['runs', '执行记录'],
|
||||
['records', '操作审计'],
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="automation-workspace" aria-labelledby="automation-title">
|
||||
<header className="workbench-heading">
|
||||
<div>
|
||||
<h1 id="automation-title">自动化</h1>
|
||||
<p>按北京时间(UTC+8)统一调度多实例运维操作。</p>
|
||||
</div>
|
||||
{tab === 'schedules' ? (
|
||||
<button type="button" className="primary-action" onClick={createTask}>
|
||||
创建任务
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="workbench-tabs" role="tablist" aria-label="自动化视图">
|
||||
{tabs.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="state-panel state-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{tab === 'schedules' ? (
|
||||
<div role="tabpanel" aria-label="计划任务">
|
||||
{loading ? <p role="status">正在加载计划任务...</p> : null}
|
||||
{!loading && tasks.length === 0 ? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无计划任务</strong>
|
||||
<span>创建定时重启、系统重启或短信发送任务。</span>
|
||||
</div>
|
||||
) : null}
|
||||
{tasks.length > 0 ? (
|
||||
<div className="schedule-table-wrap">
|
||||
<table className="schedule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>Cron</th>
|
||||
<th>下次执行</th>
|
||||
<th>启用</th>
|
||||
<th>
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id}>
|
||||
<th scope="row">
|
||||
<strong>{task.name}</strong>
|
||||
<small>v{task.version}</small>
|
||||
</th>
|
||||
<td>
|
||||
<span className="operation-chip" data-operation={task.operationType}>
|
||||
{operationLabel(task.operationType)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{targetLabel(task)}</td>
|
||||
<td>
|
||||
<code>{task.cronExpression}</code>
|
||||
</td>
|
||||
<td>
|
||||
{task.enabled && task.nextDueAt
|
||||
? `${beijingTime(task.nextDueAt)} CST`
|
||||
: '已暂停'}
|
||||
</td>
|
||||
<td>
|
||||
<label className="compact-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.enabled}
|
||||
aria-label={`启用 ${task.name}`}
|
||||
onChange={(event) => void toggle(task, event.currentTarget.checked)}
|
||||
/>
|
||||
<span aria-hidden="true" />
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-action-menu">
|
||||
<button
|
||||
type="button"
|
||||
className="row-action-trigger"
|
||||
aria-label={`任务操作 ${task.name}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={actionMenuId === task.id}
|
||||
onClick={(event) => {
|
||||
returnFocusRef.current = event.currentTarget;
|
||||
setActionMenuId((current) =>
|
||||
current === task.id ? undefined : task.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
{actionMenuId === task.id ? (
|
||||
<div
|
||||
className="row-actions"
|
||||
role="menu"
|
||||
aria-label={`${task.name} 操作`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={`编辑 ${task.name}`}
|
||||
onClick={() => {
|
||||
setActionMenuId(undefined);
|
||||
editTask(task);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={`复制 ${task.name}`}
|
||||
onClick={() => {
|
||||
setActionMenuId(undefined);
|
||||
void duplicate(task);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setActionMenuId(undefined);
|
||||
void dataSource.runNow(task.id);
|
||||
}}
|
||||
>
|
||||
立即执行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="danger-link"
|
||||
onClick={() => {
|
||||
setActionMenuId(undefined);
|
||||
void remove(task);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'runs' ? (
|
||||
<div role="tabpanel" aria-label="执行记录">
|
||||
{runsContent ??
|
||||
(runs.length === 0 ? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无执行记录</strong>
|
||||
<span>任务执行后,结果会显示在这里。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="schedule-table-wrap">
|
||||
<table className="schedule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>计划时间</th>
|
||||
<th>任务</th>
|
||||
<th>操作</th>
|
||||
<th>实例数</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>{beijingTime(run.dueAt)} CST</td>
|
||||
<th scope="row">{run.taskName}</th>
|
||||
<td>{operationLabel(run.operationType)}</td>
|
||||
<td>{run.targetSnapshot.length}</td>
|
||||
<td>{outcomeLabel(run.outcome)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'records' ? (
|
||||
<div role="tabpanel" aria-label="操作审计">
|
||||
{recordsContent ?? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无操作审计</strong>
|
||||
<span>安全追踪与故障排查记录会显示在这里。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editorOpen ? (
|
||||
<div
|
||||
className="drawer-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) closeEditor();
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="schedule-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="schedule-editor-title"
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="drawer-eyebrow">{editingTask ? '任务设置' : '新建自动化'}</span>
|
||||
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="关闭任务编辑器"
|
||||
onClick={closeEditor}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<label>
|
||||
<span>任务名称</span>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => field('name', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>操作类型</span>
|
||||
<select
|
||||
value={form.operationType}
|
||||
onChange={(event) =>
|
||||
field('operationType', event.currentTarget.value as typeof form.operationType)
|
||||
}
|
||||
>
|
||||
<option value="restart-service">重启 SimAdmin 服务</option>
|
||||
<option value="reboot-system">重启设备系统</option>
|
||||
<option value="send-sms">发送短信</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<section>
|
||||
<h3>执行目标</h3>
|
||||
<label>
|
||||
<span>目标方式</span>
|
||||
<select
|
||||
value={form.targetMode}
|
||||
onChange={(event) =>
|
||||
field('targetMode', event.currentTarget.value as typeof form.targetMode)
|
||||
}
|
||||
>
|
||||
<option value="fixed">固定实例</option>
|
||||
<option value="tags">动态标签</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.targetMode === 'fixed' ? (
|
||||
<label>
|
||||
<span>实例 ID</span>
|
||||
<input
|
||||
required
|
||||
value={form.fixedIds}
|
||||
placeholder="node-a, node-b"
|
||||
onChange={(event) => field('fixedIds', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
<span>标签匹配</span>
|
||||
<select
|
||||
value={form.tagMatch}
|
||||
onChange={(event) =>
|
||||
field('tagMatch', event.currentTarget.value as typeof form.tagMatch)
|
||||
}
|
||||
>
|
||||
<option value="any">匹配任一标签</option>
|
||||
<option value="all">匹配全部标签</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>标签</span>
|
||||
<input
|
||||
required
|
||||
value={form.tags}
|
||||
placeholder="lab, east"
|
||||
onChange={(event) => field('tags', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{form.operationType === 'send-sms' ? (
|
||||
<section>
|
||||
<h3>短信内容</h3>
|
||||
{editingTask?.sms ? (
|
||||
<p className="field-note">已配置短信。收件号码与内容均留空即可保留原配置。</p>
|
||||
) : null}
|
||||
<label>
|
||||
<span>收件号码</span>
|
||||
<input
|
||||
required={!editingTask?.sms}
|
||||
value={form.recipients}
|
||||
placeholder={
|
||||
editingTask?.sms ? '留空以保留当前收件号码' : '13800138000, 13900139000'
|
||||
}
|
||||
onChange={(event) => field('recipients', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>短信内容</span>
|
||||
<textarea
|
||||
required={!editingTask?.sms}
|
||||
rows={4}
|
||||
value={form.content}
|
||||
placeholder={editingTask?.sms ? '留空以保留当前短信内容' : undefined}
|
||||
onChange={(event) => field('content', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
) : null}
|
||||
<section>
|
||||
<h3>调度时间</h3>
|
||||
<label>
|
||||
<span>五段 Cron 表达式</span>
|
||||
<div className="inline-field">
|
||||
<input
|
||||
required
|
||||
value={form.cronExpression}
|
||||
onChange={(event) => {
|
||||
field('cronExpression', event.currentTarget.value);
|
||||
setPreview([]);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void dataSource.previewCron(form.cronExpression).then((items) => {
|
||||
setPreview(items);
|
||||
setConfirmedFrequency(false);
|
||||
})
|
||||
}
|
||||
>
|
||||
预览后续执行
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<p className="field-note">北京时间 · Asia/Shanghai · UTC+8</p>
|
||||
<div className="advanced-grid schedule-window">
|
||||
<label>
|
||||
<span>生效开始(可选)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.effectiveStart}
|
||||
onChange={(event) => field('effectiveStart', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>生效结束(可选)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.effectiveEnd}
|
||||
onChange={(event) => field('effectiveEnd', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{preview.length ? (
|
||||
<ol className="cron-preview">
|
||||
{preview.map((item) => (
|
||||
<li key={item}>{beijingTime(item)} CST</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
{highFrequencyRestart ? (
|
||||
<label className="risk-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedFrequency}
|
||||
disabled={preview.length !== 5}
|
||||
onChange={(event) => setConfirmedFrequency(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>此重启任务可能高频执行,我已查看并确认未来 5 次运行时间。</span>
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
<details>
|
||||
<summary>高级规则</summary>
|
||||
<div className="advanced-grid">
|
||||
<label>
|
||||
<span>错过执行</span>
|
||||
<select
|
||||
value={form.misfirePolicy}
|
||||
onChange={(event) =>
|
||||
field(
|
||||
'misfirePolicy',
|
||||
event.currentTarget.value as typeof form.misfirePolicy,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="skip">跳过</option>
|
||||
<option value="catch-up-once">补执行一次</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>任务重叠</span>
|
||||
<select
|
||||
value={form.overlapPolicy}
|
||||
onChange={(event) =>
|
||||
field(
|
||||
'overlapPolicy',
|
||||
event.currentTarget.value as typeof form.overlapPolicy,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="skip">跳过</option>
|
||||
<option value="queue-once">排队一次</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>重试次数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={form.operationType === 'reboot-system' ? 0 : 10}
|
||||
value={form.maxRetries}
|
||||
onChange={(event) => field('maxRetries', Number(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>重试间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="86400"
|
||||
value={form.retryInterval}
|
||||
onChange={(event) =>
|
||||
field('retryInterval', Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
{reviewing ? (
|
||||
<section
|
||||
className="schedule-confirmation"
|
||||
role="group"
|
||||
aria-labelledby="schedule-confirmation-title"
|
||||
>
|
||||
<h3 id="schedule-confirmation-title">最终确认</h3>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>操作</dt>
|
||||
<dd>{operationLabel(form.operationType)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>目标</dt>
|
||||
<dd>
|
||||
{form.targetMode === 'fixed'
|
||||
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
|
||||
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>计划</dt>
|
||||
<dd>
|
||||
<code>{form.cronExpression.trim()}</code> · 北京时间
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>风险</dt>
|
||||
<dd>{form.operationType === 'reboot-system' ? 'R3' : 'R2'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label className="risk-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedDefinition}
|
||||
onChange={(event) => setConfirmedDefinition(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>我已核对操作、执行目标、Cron 计划与风险等级。</span>
|
||||
</label>
|
||||
</section>
|
||||
) : null}
|
||||
<footer>
|
||||
<button type="button" onClick={closeEditor}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-action"
|
||||
disabled={
|
||||
saving ||
|
||||
(highFrequencyRestart && (preview.length !== 5 || !confirmedFrequency)) ||
|
||||
(reviewing && !confirmedDefinition)
|
||||
}
|
||||
>
|
||||
{saving
|
||||
? '正在保存...'
|
||||
: reviewing
|
||||
? editingTask
|
||||
? '确认并保存'
|
||||
: '确认并创建'
|
||||
: '检查并继续'}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ describe('Fleet API data source', () => {
|
||||
memoryPercent: 67.8,
|
||||
maxTemperatureCelsius: 52.6,
|
||||
phoneNumbers: ['13800000000'],
|
||||
version: '1.9.4',
|
||||
}
|
||||
: {
|
||||
items: [
|
||||
@@ -36,7 +37,13 @@ describe('Fleet API data source', () => {
|
||||
);
|
||||
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
||||
expect(snapshot.instances).toEqual([
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'], revision: 4 },
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
url: 'https://alpha.example/admin',
|
||||
tags: ['lab'],
|
||||
revision: 4,
|
||||
},
|
||||
]);
|
||||
expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({
|
||||
cpuPercent: 23.4,
|
||||
@@ -44,6 +51,7 @@ describe('Fleet API data source', () => {
|
||||
maxTemperatureCelsius: 52.6,
|
||||
phoneNumbers: ['13800000000'],
|
||||
});
|
||||
expect(snapshot.statuses.get('alpha')?.summary?.version).toBe('1.9.4');
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/instances',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
@@ -81,12 +89,15 @@ describe('Fleet API data source', () => {
|
||||
);
|
||||
});
|
||||
const partials: unknown[] = [];
|
||||
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(undefined, (snapshot) => {
|
||||
partials.push({
|
||||
resources: snapshot.statuses.get('alpha')?.summary?.resources,
|
||||
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
|
||||
});
|
||||
});
|
||||
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(
|
||||
undefined,
|
||||
(snapshot) => {
|
||||
partials.push({
|
||||
resources: snapshot.statuses.get('alpha')?.summary?.resources,
|
||||
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
|
||||
});
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(partials.length).toBe(1));
|
||||
expect(partials[0]).toEqual({ resources: undefined, freshness: 'unknown' });
|
||||
resolveResources(
|
||||
|
||||
@@ -41,6 +41,11 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
|
||||
maxTemperatureCelsius?: number;
|
||||
phoneNumbers?: string[];
|
||||
} = {};
|
||||
const version = string(body.version)
|
||||
? body.version
|
||||
: string(record(body.health)?.version)
|
||||
? (record(body.health)?.version as string)
|
||||
: undefined;
|
||||
if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent;
|
||||
if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent;
|
||||
if (finite(body.maxTemperatureCelsius))
|
||||
@@ -52,6 +57,7 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
|
||||
resources.phoneNumbers = body.phoneNumbers as string[];
|
||||
return {
|
||||
freshness: 'fresh',
|
||||
...(version ? { version } : {}),
|
||||
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
||||
};
|
||||
}
|
||||
@@ -87,12 +93,7 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
const listBody = record(await readJson(listResponse));
|
||||
if (
|
||||
!listResponse.ok ||
|
||||
!listBody ||
|
||||
!Array.isArray(listBody.items) ||
|
||||
!record(listBody.page)
|
||||
)
|
||||
if (!listResponse.ok || !listBody || !Array.isArray(listBody.items) || !record(listBody.page))
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const instances = listBody.items.map(parseInstance);
|
||||
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
|
||||
|
||||
@@ -20,7 +20,11 @@ const snapshot: FleetSnapshot = {
|
||||
{
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
||||
summary: {
|
||||
version: '1.1.6',
|
||||
freshness: 'fresh',
|
||||
resources: { cpuPercent: 24, memoryPercent: 51, maxTemperatureCelsius: 42 },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -43,16 +47,97 @@ describe('FleetPage card navigation', () => {
|
||||
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||
);
|
||||
expect(within(card).getByRole('meter', { name: 'CPU 使用率' }).getAttribute('value')).toBe(
|
||||
'24',
|
||||
);
|
||||
expect(within(card).getByRole('meter', { name: '内存使用率' }).getAttribute('value')).toBe(
|
||||
'51',
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
||||
expect(
|
||||
within(card).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
|
||||
).toBe('24');
|
||||
expect(
|
||||
within(card).getByRole('progressbar', { name: '内存使用率' }).getAttribute('aria-valuenow'),
|
||||
).toBe('51');
|
||||
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
||||
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
const nodeEntry = within(card).getByRole('link', { name: '打开 Alpha modem 节点入口' });
|
||||
expect(nodeEntry.getAttribute('href')).toBe('http://192.168.1.2');
|
||||
expect(nodeEntry.getAttribute('target')).toBe('_blank');
|
||||
expect(nodeEntry.getAttribute('rel')).toContain('noopener');
|
||||
expect(within(card).queryByRole('group', { name: '实例运维操作' })).toBeNull();
|
||||
expect(within(card).queryByRole('button', { name: '重启服务 Alpha modem' })).toBeNull();
|
||||
const actionTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
||||
expect(actionTrigger.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(actionTrigger);
|
||||
expect(actionTrigger.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
|
||||
const metadata = card.querySelector('.fleet-card-metadata');
|
||||
const hardware = within(card).getByRole('group', { name: '节点硬件信息' });
|
||||
const telemetry = within(card).getByRole('region', { name: '资源遥测' });
|
||||
const footer = within(card).getByRole('region', { name: '短信状态' });
|
||||
expect(metadata).toBeTruthy();
|
||||
expect(
|
||||
Boolean(metadata!.compareDocumentPosition(hardware) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Boolean(hardware.compareDocumentPosition(telemetry) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Boolean(telemetry.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
|
||||
const withoutVersion: FleetSnapshot = {
|
||||
...snapshot,
|
||||
statuses: new Map([
|
||||
[
|
||||
'alpha',
|
||||
{
|
||||
...snapshot.statuses.get('alpha')!,
|
||||
summary: {
|
||||
freshness: 'fresh',
|
||||
resources: { cpuPercent: 24, memoryPercent: 51 },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
render(<FleetPage initialData={withoutVersion} />);
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
expect(within(card).getByText('版本未知')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports the menu button keyboard model and dismisses the menu from outside', () => {
|
||||
render(<FleetPage initialData={snapshot} />);
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const trigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
||||
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
|
||||
|
||||
const serviceRestart = within(card).getByRole('menuitem', {
|
||||
name: '重启服务 Alpha modem',
|
||||
});
|
||||
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
|
||||
expect(document.activeElement).toBe(serviceRestart);
|
||||
|
||||
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(systemReboot);
|
||||
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(serviceRestart);
|
||||
fireEvent.keyDown(serviceRestart, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(systemReboot);
|
||||
|
||||
fireEvent.keyDown(systemReboot, { key: 'Escape' });
|
||||
expect(within(card).queryByRole('menu')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(within(card).getByRole('menu')).toBeTruthy();
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(within(card).queryByRole('menu')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,9 +163,59 @@ describe('FleetPage search and filter toolbar', () => {
|
||||
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
});
|
||||
|
||||
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
|
||||
const groupedSnapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
{ ...snapshot.instances[0]!, tags: ['核心'] },
|
||||
{
|
||||
id: 'beta',
|
||||
name: 'Beta modem',
|
||||
url: 'http://192.168.1.3',
|
||||
tags: ['外场'],
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
statuses: new Map([
|
||||
...snapshot.statuses,
|
||||
['beta', { reachable: false, authenticated: false }],
|
||||
]),
|
||||
};
|
||||
render(<FleetPage initialData={groupedSnapshot} />);
|
||||
|
||||
const groups = screen.getByRole('group', { name: '节点分组' });
|
||||
const betaCard = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
expect(within(betaCard).getAllByText('--')).toHaveLength(3);
|
||||
expect(within(groups).getByRole('button', { name: '全部节点' })).toBeTruthy();
|
||||
fireEvent.click(within(groups).getByRole('button', { name: '核心' }));
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
||||
expect(screen.queryByRole('article', { name: 'Beta modem 实例概览' })).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('search', { name: '实例搜索与筛选' })
|
||||
.closest('aside')
|
||||
?.classList.contains('fleet-sidebar'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetPage batch and restart actions', () => {
|
||||
it('keeps selection controls hidden until batch-selection mode is entered', () => {
|
||||
render(<FleetPage initialData={snapshot} />);
|
||||
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
const checkbox = screen.getByRole('checkbox', { name: /选择 Alpha modem/ });
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.closest('.fleet-card-header-meta')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '全选本页' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出批量选择' }));
|
||||
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes card restart controls and batch restart entry for selected instances', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
id: 'prep-1',
|
||||
@@ -123,17 +258,19 @@ describe('FleetPage batch and restart actions', () => {
|
||||
}));
|
||||
vi.stubGlobal('confirm', () => true);
|
||||
render(
|
||||
<FleetPage
|
||||
initialData={snapshot}
|
||||
operationClient={{ list, prepare, execute } as never}
|
||||
/>,
|
||||
<FleetPage initialData={snapshot} operationClient={{ list, prepare, execute } as never} />,
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
const cardMenuTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
||||
expect(cardMenuTrigger.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(cardMenuTrigger);
|
||||
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
|
||||
expect(screen.getByText(/已选择 1 项/).textContent).toMatch(/已选择 1 项/);
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量操作' }));
|
||||
expect(
|
||||
screen.getAllByText(/已选择 1 项/).some((node) => /已选择 1 项/.test(node.textContent ?? '')),
|
||||
).toBe(true);
|
||||
const batch = await screen.findByRole('region', { name: '批量操作入口' });
|
||||
expect(batch.textContent).toMatch(/已选择 1 项/);
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量重启服务' }));
|
||||
|
||||
+734
-421
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Button, Card, Title } from 'animal-island-ui';
|
||||
|
||||
import {
|
||||
createInstanceApiDataSource,
|
||||
@@ -146,7 +147,9 @@ export function InstanceEditor({
|
||||
|
||||
return (
|
||||
<section className="instance-editor">
|
||||
<h1>{mode === 'create' ? '添加实例' : '实例设置'}</h1>
|
||||
<h1>
|
||||
<Title color="app-green">{mode === 'create' ? '添加实例' : '实例设置'}</Title>
|
||||
</h1>
|
||||
{error ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
{error}
|
||||
@@ -157,82 +160,84 @@ export function InstanceEditor({
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
名称
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
源地址
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">使用英文逗号分隔标签</small>
|
||||
<label>
|
||||
认证方式
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
密码操作
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
setPasswordAction(event.target.value as PasswordAction);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">保留已保存的密码</option>
|
||||
<option value="set">设置新密码</option>
|
||||
<option value="clear">清除已保存的密码</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<div className="form-actions">
|
||||
<button disabled={busy} type="submit">
|
||||
{mode === 'create' ? '添加实例' : '保存更改'}
|
||||
</button>
|
||||
{mode === 'edit' ? (
|
||||
<button disabled={busy} type="button" onClick={() => void testConnection()}>
|
||||
测试连接
|
||||
</button>
|
||||
<Card pattern="default" className="editor-card">
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
名称
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
源地址
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">使用英文逗号分隔标签</small>
|
||||
<label>
|
||||
认证方式
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
密码操作
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
setPasswordAction(event.target.value as PasswordAction);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">保留已保存的密码</option>
|
||||
<option value="set">设置新密码</option>
|
||||
<option value="clear">清除已保存的密码</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
<div className="form-actions">
|
||||
<Button disabled={busy} loading={busy} htmlType="submit" type="primary">
|
||||
{mode === 'create' ? '添加实例' : '保存更改'}
|
||||
</Button>
|
||||
{mode === 'edit' ? (
|
||||
<Button disabled={busy} htmlType="button" onClick={() => void testConnection()}>
|
||||
测试连接
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{mode === 'edit' ? (
|
||||
<section className="danger-zone" aria-labelledby="danger-heading">
|
||||
<h2 id="danger-heading">危险操作区</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Tag, Title } from 'animal-island-ui';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
|
||||
@@ -124,7 +125,9 @@ export function InstanceDetail({
|
||||
if (!ownsRoute) {
|
||||
return (
|
||||
<section>
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
<h1>
|
||||
<Title>{INSTANCE_MODULE_LABELS[module]}</Title>
|
||||
</h1>
|
||||
<p>此路由缺少实例上下文。</p>
|
||||
</section>
|
||||
);
|
||||
@@ -142,16 +145,35 @@ export function InstanceDetail({
|
||||
</a>
|
||||
<div className="instance-identity">
|
||||
<div>
|
||||
<h1>{instance.name}</h1>
|
||||
<h1>
|
||||
<Title color="app-teal">{instance.name}</Title>
|
||||
</h1>
|
||||
<code>{instance.id}</code>
|
||||
</div>
|
||||
<div className="instance-context-badges">
|
||||
{instance.status !== 'unknown' ? <span>{displayStatus(instance.status)}</span> : null}
|
||||
{instance.status !== 'unknown' ? (
|
||||
<Tag
|
||||
size="small"
|
||||
color={
|
||||
instance.status === 'online'
|
||||
? 'app-teal'
|
||||
: instance.status === 'offline'
|
||||
? 'app-red'
|
||||
: 'app-yellow'
|
||||
}
|
||||
>
|
||||
{displayStatus(instance.status)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{instance.authentication !== 'unknown' ? (
|
||||
<span>{AUTH_LABELS[instance.authentication]}</span>
|
||||
<Tag size="small" color="app-yellow">
|
||||
{AUTH_LABELS[instance.authentication]}
|
||||
</Tag>
|
||||
) : null}
|
||||
{instance.freshness !== 'unknown' ? (
|
||||
<span>{FRESHNESS_LABELS[instance.freshness]}</span>
|
||||
<Tag size="small" color="app-blue">
|
||||
{FRESHNESS_LABELS[instance.freshness]}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,7 +207,11 @@ export function InstanceDetail({
|
||||
</nav>
|
||||
</header>
|
||||
<div className="instance-module-detail">
|
||||
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
|
||||
<h2>
|
||||
<Title size="small" color="app-green">
|
||||
{INSTANCE_MODULE_LABELS[module]}
|
||||
</Title>
|
||||
</h2>
|
||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||
{!loading && canOpen(module, activeCapability) ? (
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
|
||||
async load(instanceId, signal): Promise<MessagesSnapshot> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`,
|
||||
{ headers: { accept: 'application/json' }, signal },
|
||||
{ headers: { accept: 'application/json' }, credentials: 'same-origin', signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('message load failed');
|
||||
const root = record(await response.json());
|
||||
@@ -50,6 +50,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -243,12 +243,12 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={1600}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="composer-footer">
|
||||
<small>{content.length} / 1600 字符</small>
|
||||
<small>{content.length} / 2000 字符</small>
|
||||
<button type="submit" disabled={sending || !content.trim()}>
|
||||
{sending ? '正在发送…' : '发送短信'}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.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';
|
||||
|
||||
export type OverviewFieldValue = string | number | boolean | null;
|
||||
@@ -51,11 +48,22 @@ const SECTIONS = [
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
Model: '型号',
|
||||
Manufacturer: '制造商',
|
||||
IMEI: 'IMEI',
|
||||
Version: '固件版本',
|
||||
Uptime: '运行时间',
|
||||
Slots: '卡槽数',
|
||||
Active: '活跃数',
|
||||
Operator: '运营商',
|
||||
Technology: '接入制式',
|
||||
Signal: '信号强度',
|
||||
MCC: 'MCC',
|
||||
MNC: 'MNC',
|
||||
Registration: '注册状态',
|
||||
IPv4: 'IPv4 地址',
|
||||
IPv6: 'IPv6 地址',
|
||||
Download: '下行速率',
|
||||
Upload: '上行速率',
|
||||
'Messages today': '今日消息数',
|
||||
Calls: '通话数',
|
||||
Usage: '使用率',
|
||||
@@ -214,7 +222,8 @@ export function OverviewSystemPage({
|
||||
return;
|
||||
setActionState({ busy: true, message: `正在${op.title}…` });
|
||||
try {
|
||||
await client.list({ pageSize: 100 });
|
||||
// Avoid pageSize=100 first-page truncation for late-sorted operation ids.
|
||||
await client.list({ search: op.operationId, pageSize: 100 });
|
||||
const prepared = await client.prepare({
|
||||
operationId: op.operationId,
|
||||
targets: [{ instanceId: instance.id, revision: targetRevision }],
|
||||
@@ -235,7 +244,8 @@ export function OverviewSystemPage({
|
||||
const job = await client.execute(prepared.id);
|
||||
setActionState({
|
||||
busy: false,
|
||||
message: job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
|
||||
message:
|
||||
job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
|
||||
...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from 'animal-island-ui';
|
||||
import { displayStatus } from '../ui/locale.js';
|
||||
|
||||
import {
|
||||
@@ -327,13 +328,14 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
htmlType="button"
|
||||
size="small"
|
||||
aria-label="刷新任务"
|
||||
onClick={() => setManualRefresh((value) => value + 1)}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status" aria-label="任务加载状态">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import 'animal-island-ui/style';
|
||||
|
||||
import { ConsoleAuthGate } from './auth/console-auth.js';
|
||||
import { AppShell } from './app-shell.js';
|
||||
|
||||
@@ -104,8 +104,13 @@ describe('safe operation client', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
|
||||
const fetcher = vi.fn(async () => response(page));
|
||||
it('refuses uncatalogued operations and mismatched schemas without a prepare request', async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (String(url).includes('search=hiddenOp')) {
|
||||
return response({ items: [], page: { page: 1, pageSize: 100, total: 0 } });
|
||||
}
|
||||
return response(page);
|
||||
});
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list();
|
||||
|
||||
@@ -123,7 +128,52 @@ describe('safe operation client', () => {
|
||||
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
||||
}),
|
||||
).rejects.toThrow('Operation parameter schema does not match the catalog.');
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
// list + one search hydrate for the missing op; no prepare POST.
|
||||
expect(fetcher.mock.calls.every((call) => !String(call[0]).includes('/prepare'))).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates late-sorted restart ops when the first pageSize=100 page omits them', async () => {
|
||||
const restartEntry = {
|
||||
operationId: 'postServiceRestart',
|
||||
title: 'Restart Service',
|
||||
risk: 'R3',
|
||||
capability: 'job',
|
||||
batchable: false,
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
};
|
||||
const firstPage = {
|
||||
items: [entry],
|
||||
page: { page: 1, pageSize: 100, total: 117 },
|
||||
};
|
||||
const searchPage = {
|
||||
items: [restartEntry],
|
||||
page: { page: 1, pageSize: 100, total: 1 },
|
||||
};
|
||||
const restartPreparation = {
|
||||
...preparation,
|
||||
operationId: 'postServiceRestart',
|
||||
risk: 'R3',
|
||||
confirmationPrompt: 'Confirm restart service',
|
||||
};
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(firstPage))
|
||||
.mockResolvedValueOnce(response(searchPage))
|
||||
.mockResolvedValueOnce(response(restartPreparation));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list({ pageSize: 100 });
|
||||
|
||||
const result = await client.prepare({
|
||||
operationId: 'postServiceRestart',
|
||||
targets: [{ instanceId: 'instance-1', revision: 1 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
fields: [],
|
||||
},
|
||||
});
|
||||
expect(result.operationId).toBe('postServiceRestart');
|
||||
expect(String(fetcher.mock.calls[1]?.[0])).toContain('search=postServiceRestart');
|
||||
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/operations/prepare');
|
||||
});
|
||||
|
||||
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
|
||||
|
||||
@@ -369,25 +369,39 @@ function queryString(query: OperationCatalogQuery): string {
|
||||
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
|
||||
const catalog = new Map<string, OperationCatalogEntry>();
|
||||
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
|
||||
|
||||
async function list(query: OperationCatalogQuery = {}): Promise<OperationCatalogPage> {
|
||||
const body = await jsonResponse(
|
||||
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
const parsed = parseCatalog(body);
|
||||
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||
// Merge rather than replace: a pageSize-capped first page must not wipe later lookups
|
||||
// for late-sorted ids such as postServiceRestart / postSystemReboot.
|
||||
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function ensureCatalogEntry(
|
||||
operationId: string,
|
||||
): Promise<OperationCatalogEntry | undefined> {
|
||||
const existing = catalog.get(operationId);
|
||||
if (existing) return existing;
|
||||
// Catalog is sorted by operationId and capped at pageSize 100; hydrate by exact search.
|
||||
await list({ search: operationId, pageSize: 100 });
|
||||
return catalog.get(operationId);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(query = {}) {
|
||||
const body = await jsonResponse(
|
||||
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
const parsed = parseCatalog(body);
|
||||
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||
catalog.clear();
|
||||
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||
return parsed;
|
||||
},
|
||||
list,
|
||||
async prepare(input) {
|
||||
const safeInput = safePrepareInput(input);
|
||||
if (!safeInput) throw new Error('Operation preparation request is invalid.');
|
||||
const allowed = catalog.get(safeInput.operationId);
|
||||
const allowed = await ensureCatalogEntry(safeInput.operationId);
|
||||
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
|
||||
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
|
||||
throw new Error('Operation parameter schema does not match the catalog.');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Tag, Title } from 'animal-island-ui';
|
||||
|
||||
import {
|
||||
canonicalHttpOrigin,
|
||||
@@ -199,7 +200,9 @@ export function InstanceSettingsPage({
|
||||
<section aria-labelledby="settings-instances-title">
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="settings-instances-title">实例</h1>
|
||||
<h1 id="settings-instances-title">
|
||||
<Title color="app-green">实例</Title>
|
||||
</h1>
|
||||
<p>配置此工作区可用的 SimAdmin 实例。</p>
|
||||
</div>
|
||||
<a href="/instances/new">添加实例</a>
|
||||
@@ -236,55 +239,70 @@ export function InstanceSettingsPage({
|
||||
const authentication = authLabel(status);
|
||||
return (
|
||||
<li key={instance.id} aria-label={displayName}>
|
||||
<h2>
|
||||
<a href={`/settings/instances/${encodeURIComponent(instance.id)}`}>
|
||||
{displayName}
|
||||
</a>
|
||||
</h2>
|
||||
{instance.name ? <p>{instance.id}</p> : null}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>源地址</dt>
|
||||
<dd>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
'源地址无效'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{state ? (
|
||||
<Card pattern="default" hoverable>
|
||||
<h2>
|
||||
<a href={`/settings/instances/${encodeURIComponent(instance.id)}`}>
|
||||
{displayName}
|
||||
</a>
|
||||
</h2>
|
||||
{instance.name ? <p>{instance.id}</p> : null}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{state}</dd>
|
||||
<dt>源地址</dt>
|
||||
<dd>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
'源地址无效'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{authentication ? (
|
||||
<div>
|
||||
<dt>认证</dt>
|
||||
<dd>{authentication}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.freshness ? (
|
||||
<div>
|
||||
<dt>数据新鲜度</dt>
|
||||
<dd>{freshnessLabel(status.freshness)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.capabilities?.length ? (
|
||||
<div>
|
||||
<dt>能力</dt>
|
||||
<dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{state ? (
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<Tag
|
||||
size="small"
|
||||
color={
|
||||
state === '在线'
|
||||
? 'app-teal'
|
||||
: state === '离线'
|
||||
? 'app-red'
|
||||
: 'app-yellow'
|
||||
}
|
||||
>
|
||||
{state}
|
||||
</Tag>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{authentication ? (
|
||||
<div>
|
||||
<dt>认证</dt>
|
||||
<dd>{authentication}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.freshness ? (
|
||||
<div>
|
||||
<dt>数据新鲜度</dt>
|
||||
<dd>{freshnessLabel(status.freshness)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.capabilities?.length ? (
|
||||
<div>
|
||||
<dt>能力</dt>
|
||||
<dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
+4650
-1399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'activity'
|
||||
| 'alert'
|
||||
| 'arrow-up-right'
|
||||
| 'check'
|
||||
| 'close'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'cpu'
|
||||
| 'filter'
|
||||
| 'globe'
|
||||
| 'grid'
|
||||
| 'history'
|
||||
| 'jobs'
|
||||
| 'memory'
|
||||
| 'message'
|
||||
| 'more'
|
||||
| 'phone'
|
||||
| 'plus'
|
||||
| 'restart'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'tag'
|
||||
| 'temperature'
|
||||
| 'version'
|
||||
| 'wifi';
|
||||
|
||||
const paths: Readonly<Record<IconName, readonly string[]>> = {
|
||||
activity: ['M3 12h4l2.5-7 5 14 2.5-7H21'],
|
||||
alert: [
|
||||
'M12 9v4',
|
||||
'M12 17h.01',
|
||||
'M10.3 3.7 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z',
|
||||
],
|
||||
'arrow-up-right': ['M7 17 17 7', 'M7 7h10v10'],
|
||||
check: ['m5 12 4 4L19 6'],
|
||||
close: ['M6 6l12 12M18 6 6 18'],
|
||||
'chevron-left': ['m15 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'],
|
||||
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
|
||||
globe: [
|
||||
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
||||
'M3 12h18',
|
||||
'M12 3c2.4 2.5 3.7 5.5 3.7 9S14.4 18.5 12 21c-2.4-2.5-3.7-5.5-3.7-9S9.6 5.5 12 3Z',
|
||||
],
|
||||
grid: ['M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z'],
|
||||
history: ['M3 12a9 9 0 1 0 3-6.7L3 8', 'M3 3v5h5', 'M12 7v5l3 2'],
|
||||
jobs: ['M4 7h16v13H4z', 'M9 7V4h6v3', 'M4 12h16', 'M10 12v2h4v-2'],
|
||||
memory: ['M5 7h14v10H5z', 'M8 10v4M12 10v4M16 10v4', 'M3 9h2M3 15h2M19 9h2M19 15h2'],
|
||||
message: ['M4 5h16v11H8l-4 4Z', 'M8 9h8M8 12h5'],
|
||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||
phone: [
|
||||
'M7 3h3l1.5 4-2 1.5a15 15 0 0 0 6 6L17 12.5l4 1.5v3c0 2.2-1.8 4-4 4A14 14 0 0 1 3 7c0-2.2 1.8-4 4-4Z',
|
||||
],
|
||||
plus: ['M12 5v14M5 12h14'],
|
||||
restart: ['M20 11a8 8 0 1 0-2.3 5.7', 'M20 4v7h-7'],
|
||||
search: ['M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16ZM17 17l4 4'],
|
||||
server: ['M4 4h16v6H4zM4 14h16v6H4z', 'M8 7h.01M8 17h.01'],
|
||||
settings: [
|
||||
'M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
|
||||
'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z',
|
||||
],
|
||||
tag: ['M20 13 13 20l-9-9V4h7Z', 'M8.5 8.5h.01'],
|
||||
temperature: ['M10 14.8V5a2 2 0 1 1 4 0v9.8a4 4 0 1 1-4 0Z', 'M12 17v-7'],
|
||||
version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'],
|
||||
wifi: [
|
||||
'M3 8.5a14 14 0 0 1 18 0',
|
||||
'M6.5 12a9 9 0 0 1 11 0',
|
||||
'M10 15.5a4 4 0 0 1 4 0',
|
||||
'M12 19h.01',
|
||||
],
|
||||
};
|
||||
|
||||
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{paths[name].map((path, index) => (
|
||||
<path d={path} key={`${name}-${index}`} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+16
-2
@@ -1,6 +1,20 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [react()],
|
||||
});
|
||||
resolve: {
|
||||
alias:
|
||||
mode === 'test'
|
||||
? [
|
||||
{
|
||||
find: /^animal-island-ui$/u,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./node_modules/animal-island-ui/dist/cjs/index.cjs', import.meta.url),
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user