feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
+6 -1
View File
@@ -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>
+2
View File
@@ -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"
},
+87 -20
View File
@@ -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
View File
@@ -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>
);
}
+1
View File
@@ -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

+3 -2
View File
@@ -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]) => (
+57 -52
View File
@@ -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();
});
});
+880
View File
@@ -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(
+7 -6
View File
@@ -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.');
+155 -18
View File
@@ -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: '批量重启服务' }));
File diff suppressed because it is too large Load Diff
+81 -76
View File
@@ -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>
+32 -6
View File
@@ -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),
},
);
+2 -2
View File
@@ -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>
+16 -6
View File
@@ -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) {
+5 -3
View File
@@ -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
View File
@@ -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 () => {
+29 -15
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -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
View File
@@ -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),
),
},
]
: [],
},
}));