feat(web): rebuild fleet page with Hub-style identity guard and context actions

- Surface device identity state, carrier details and upstream link health.

- Add fleet-level identity monitoring and restart/baseband context actions.

- Apply consistent notification scoping across fleet views.
This commit is contained in:
chick
2026-09-07 00:45:28 +08:00
parent 635527dd26
commit 864e1bd90c
16 changed files with 1596 additions and 90 deletions
@@ -265,4 +265,36 @@ describe('Fleet heartbeat connection state', () => {
expect(partials[0]).toMatchObject({ probed: true, reachable: false });
expect(partials.at(-1)).toMatchObject({ probed: true, reachable: false });
});
it('carries the identity guard verdict onto the row, in both passes', async () => {
const fetcher = vi.fn(async () => {
const base = overviewWith({ probed: true, reachable: true, authenticated: true });
const response = await base();
const body = await response.json();
body.items[0].identity = { tracked: true, status: 'pending', reasons: ['imei_claimed'] };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
const partials: unknown[] = [];
const snapshot = await createFleetApiDataSource(fetcher as unknown as typeof fetch).load(
undefined,
(value) => partials.push(value.statuses.get('alpha')),
);
expect(partials[0]).toMatchObject({ identity: { status: 'pending' } });
expect(snapshot.statuses.get('alpha')?.identity).toEqual({
tracked: true,
status: 'pending',
reasons: ['imei_claimed'],
});
});
it('leaves the row unbadged for a server without the identity guard', async () => {
const snapshot = await createFleetApiDataSource(
overviewWith(undefined) as unknown as typeof fetch,
).load();
expect(snapshot.statuses.get('alpha')?.identity).toBeUndefined();
});
});
+30 -6
View File
@@ -1,5 +1,5 @@
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
import type { FleetIdentityState, FleetInstance, FleetStatus } from './fleet-table-view-model.js';
const integer = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value);
@@ -126,15 +126,36 @@ function parseConnection(value: unknown): ConnectionView {
function skeletonStatuses(
instances: readonly FleetInstance[],
connections: readonly ConnectionView[],
identities: readonly (FleetIdentityState | undefined)[],
): Map<string, FleetStatus> {
return new Map(
instances.map((instance, index) => [
instance.id,
{ ...(connections[index] ?? unprobed), summary: { freshness: 'unknown' } },
]),
instances.map((instance, index) => {
const identity = identities[index];
return [
instance.id,
{
...(connections[index] ?? unprobed),
...(identity ? { identity } : {}),
summary: { freshness: 'unknown' },
},
];
}),
);
}
/** Absent on a server that predates the identity guard, which keeps the row unbadged. */
function parseIdentity(value: unknown): FleetIdentityState | undefined {
const source = record(value);
if (!source || typeof source.tracked !== 'boolean') return undefined;
return {
tracked: source.tracked,
status: source.status === 'pending' ? 'pending' : 'confirmed',
reasons: Array.isArray(source.reasons)
? source.reasons.filter((item): item is string => string(item))
: [],
};
}
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
return {
async load(signal, onPartial) {
@@ -153,9 +174,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
const connections = overviewBody.items.map((item) =>
parseConnection(record(item)?.connection),
);
const identities = overviewBody.items.map((item) => parseIdentity(record(item)?.identity));
const partial: FleetSnapshot = {
instances: readyInstances,
statuses: skeletonStatuses(readyInstances, connections),
statuses: skeletonStatuses(readyInstances, connections, identities),
};
onPartial?.(partial);
@@ -163,8 +185,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
overviewBody.items.map((item, index) => {
const instance = readyInstances[index]!;
const resources = record(item)?.resources;
const identity = identities[index];
const status: FleetStatus = {
...connections[index]!,
...(identity ? { identity } : {}),
summary: resources ? parseResources(resources) : { freshness: 'unknown' },
};
return [instance.id, status] as const;
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetIdentityPanel } from './fleet-identity-panel.js';
import type { DeviceIdentity, IdentityDataSource } from './identity-api-data-source.js';
afterEach(() => {
cleanup();
});
const pending: DeviceIdentity = {
instanceId: 'node-a',
instanceName: '机房 A',
status: 'pending',
reasons: ['imei_claimed'],
conflicts: [{ reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] }],
imei: '860000000000001',
manufacturer: 'Quectel',
model: 'RM500Q',
revision: 'v1.2',
agent: '',
origin: '',
fingerprint: 'aaaa1111',
confirmedFingerprint: 'bbbb2222',
changes: [{ field: 'model', from: 'RM500Q', to: 'RG500Q' }],
observedAt: '2026-09-05T02:00:00.000Z',
confirmedAt: '2026-09-01T02:00:00.000Z',
};
const confirmed: DeviceIdentity = {
...pending,
instanceId: 'node-b',
instanceName: '机房 B',
status: 'confirmed',
reasons: [],
conflicts: [],
changes: [],
};
function source(
items: readonly DeviceIdentity[] = [pending, confirmed],
overrides: Partial<IdentityDataSource> = {},
): IdentityDataSource & Record<string, ReturnType<typeof vi.fn>> {
return {
list: vi.fn().mockResolvedValue({
items,
tracked: items.length,
pending: items.filter((item) => item.status === 'pending').length,
}),
confirm: vi.fn().mockResolvedValue({ ...pending, status: 'confirmed', reasons: [] }),
refresh: vi.fn().mockResolvedValue({ observed: true, identity: pending }),
...overrides,
} as unknown as IdentityDataSource & Record<string, ReturnType<typeof vi.fn>>;
}
describe('FleetIdentityPanel', () => {
it('shows the evidence an operator needs to decide', async () => {
render(<FleetIdentityPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />);
const dialog = await screen.findByRole('dialog', { name: '设备身份核对' });
expect(dialog.textContent).toContain('已记录 2 台 · 待确认 1 台');
expect(screen.getByText('机房 A')).toBeTruthy();
expect(screen.getByText('同一 IMEI 出现在多台设备记录上')).toBeTruthy();
expect(screen.getByText('涉及:机房 B')).toBeTruthy();
expect(screen.getByText('RG500Q')).toBeTruthy();
expect(screen.getAllByText('型号').length).toBeGreaterThanOrEqual(2);
});
it('keeps the IMEI masked until the console-wide reveal is on', async () => {
render(<FleetIdentityPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />);
await screen.findByRole('dialog', { name: '设备身份核对' });
expect(screen.getAllByText(/0001$/u).length).toBeGreaterThan(0);
expect(screen.queryByText('860000000000001')).toBeNull();
});
it('confirms the disputed node and tells the shell to reload', async () => {
const dataSource = source();
const onChanged = vi.fn();
render(<FleetIdentityPanel dataSource={dataSource} onClose={() => {}} onChanged={onChanged} />);
await screen.findByRole('dialog', { name: '设备身份核对' });
fireEvent.click(screen.getByRole('button', { name: '确认身份' }));
await waitFor(() => expect(dataSource.confirm).toHaveBeenCalledWith('node-a'));
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
// The list reloads after the action so the badge cannot go stale.
expect(dataSource.list).toHaveBeenCalledTimes(2);
});
it('re-reads the device and reports a node with nothing to show', async () => {
const dataSource = source([pending], {
refresh: vi.fn().mockResolvedValue({ observed: false }),
});
render(<FleetIdentityPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />);
await screen.findByRole('dialog', { name: '设备身份核对' });
fireEvent.click(screen.getByRole('button', { name: '重新核对' }));
await waitFor(() => expect(dataSource.refresh).toHaveBeenCalledWith('node-a'));
expect(await screen.findByText('机房 A 未返回身份信息。')).toBeTruthy();
});
it('filters to the nodes that actually need a decision', async () => {
render(<FleetIdentityPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />);
await screen.findByRole('dialog', { name: '设备身份核对' });
fireEvent.click(screen.getByRole('button', { name: '只看待确认' }));
expect(screen.getByText('机房 A')).toBeTruthy();
expect(screen.queryByText('机房 B')).toBeNull();
});
it('explains the empty state instead of showing a blank drawer', async () => {
const dataSource = source([]);
render(<FleetIdentityPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />);
await screen.findByRole('dialog', { name: '设备身份核对' });
expect(
screen.getByText(
'尚未记录任何设备身份。打开节点的概览或硬件页后,设备会自动上报 IMEI 与型号。',
),
).toBeTruthy();
});
it('surfaces a failed load without throwing', async () => {
const dataSource = source([pending], { list: vi.fn().mockRejectedValue(new Error('down')) });
render(<FleetIdentityPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />);
expect(await screen.findByText('身份记录暂时无法读取,请重试。')).toBeTruthy();
});
it('closes on escape', async () => {
const onClose = vi.fn();
render(<FleetIdentityPanel dataSource={source()} onClose={onClose} onChanged={() => {}} />);
const dialog = await screen.findByRole('dialog', { name: '设备身份核对' });
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+289
View File
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { Tag } from 'animal-island-ui';
import { protectValue } from '../privacy/sensitive-fields.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { Icon } from '../ui/icon.js';
import type {
DeviceIdentity,
IdentityDataSource,
IdentityReason,
} from './identity-api-data-source.js';
export interface FleetIdentityPanelProps {
readonly dataSource: IdentityDataSource;
readonly onClose: () => void;
readonly onChanged: () => void;
}
const REASON_LABELS: Readonly<Record<IdentityReason, string>> = {
hardware_swapped: '硬件信息与已确认记录不符',
imei_claimed: '同一 IMEI 出现在多台设备记录上',
};
const FIELD_LABELS: Readonly<Record<string, string>> = {
imei: 'IMEI',
manufacturer: '制造商',
model: '型号',
revision: '版本',
};
const timeText = (value: string): string => {
if (!value) return '未知';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
};
/** Only the tail of a fingerprint is worth reading; the head is the same for every node. */
const tail = (value: string, length = 10): string =>
!value ? '—' : value.length <= length ? value : `${value.slice(-length)}`;
export function FleetIdentityPanel({ dataSource, onClose, onChanged }: FleetIdentityPanelProps) {
const [items, setItems] = useState<readonly DeviceIdentity[]>([]);
const [summary, setSummary] = useState<{ tracked: number; pending: number }>({
tracked: 0,
pending: 0,
});
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingOnly, setPendingOnly] = useState(false);
const closeRef = useRef<HTMLButtonElement | null>(null);
const mounted = useRef(true);
const { revealed } = useSensitiveReveal();
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const reload = useCallback(async (): Promise<void> => {
setLoading(true);
try {
const overview = await dataSource.list();
if (!mounted.current) return;
setItems(overview.items);
setSummary({ tracked: overview.tracked, pending: overview.pending });
setLoadFailed(false);
} catch {
if (!mounted.current) return;
setLoadFailed(true);
} finally {
if (mounted.current) setLoading(false);
}
}, [dataSource]);
useEffect(() => {
void reload();
}, [reload]);
useEffect(() => {
closeRef.current?.focus();
}, []);
const visible = useMemo(
() => (pendingOnly ? items.filter((item) => item.status === 'pending') : items),
[items, pendingOnly],
);
async function run(instanceId: string, operation: () => Promise<unknown>): Promise<void> {
setBusyId(instanceId);
setError(null);
setNotice(null);
try {
await operation();
await reload();
onChanged();
} catch {
if (mounted.current) setError('操作失败,请稍后重试。');
} finally {
if (mounted.current) setBusyId(null);
}
}
function handleKeyDown(event: KeyboardEvent<HTMLElement>): void {
if (event.key !== 'Escape' || busyId) return;
event.stopPropagation();
onClose();
}
return (
<div
className="drawer-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !busyId) onClose();
}}
>
<aside
className="schedule-drawer organization-drawer identity-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="identity-panel-title"
onKeyDown={handleKeyDown}
>
<header>
<div>
<h2 id="identity-panel-title"></h2>
<p className="identity-summary">
{summary.tracked}
{summary.pending > 0 ? ` · 待确认 ${summary.pending}` : ' · 全部已确认'}
</p>
</div>
<button
ref={closeRef}
type="button"
className="icon-button"
aria-label="关闭设备身份面板"
onClick={onClose}
>
<Icon name="close" />
</button>
</header>
{error ? (
<p role="alert" className="organization-alert">
{error}
</p>
) : null}
{notice ? <p className="identity-notice">{notice}</p> : null}
<div className="identity-toolbar">
<button
type="button"
className={pendingOnly ? 'is-active' : undefined}
aria-pressed={pendingOnly}
onClick={() => setPendingOnly((value) => !value)}
>
</button>
<button
type="button"
className="organization-add"
disabled={loading || busyId !== null}
onClick={() => void reload()}
>
<Icon name="restart" />
</button>
</div>
{loading ? (
<p className="organization-empty"></p>
) : loadFailed ? (
<p className="organization-empty"></p>
) : visible.length === 0 ? (
<p className="organization-empty">
{pendingOnly
? '没有待确认的设备,全部节点的身份都与记录一致。'
: '尚未记录任何设备身份。打开节点的概览或硬件页后,设备会自动上报 IMEI 与型号。'}
</p>
) : (
<ul className="identity-list">
{visible.map((identity) => (
<li key={identity.instanceId} className={`identity-card identity-${identity.status}`}>
<div className="identity-card-head">
<strong>{identity.instanceName || identity.instanceId}</strong>
{identity.status === 'pending' ? (
<Tag size="small" color="app-red" variant="soft">
<Icon name="alert" />
</Tag>
) : (
<Tag size="small" color="app-teal" variant="soft">
<Icon name="check" />
</Tag>
)}
</div>
<dl className="identity-fields">
<div>
<dt>IMEI</dt>
<dd>{protectValue('imei', identity.imei || '—', revealed)}</dd>
</div>
<div>
<dt></dt>
<dd>{identity.manufacturer || '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{identity.model || '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{identity.revision || '—'}</dd>
</div>
</dl>
{identity.changes.length > 0 ? (
<ul className="identity-changes">
{identity.changes.map((change) => (
<li key={change.field}>
<span>{FIELD_LABELS[change.field] ?? change.field}</span>
<s>{protectValue(change.field, change.from, revealed)}</s>
<em>{protectValue(change.field, change.to, revealed)}</em>
</li>
))}
</ul>
) : null}
{identity.conflicts.length > 0 ? (
<ul className="identity-reasons">
{identity.conflicts.map((conflict) => (
<li key={conflict.reason}>
<span>{REASON_LABELS[conflict.reason]}</span>
{conflict.instanceNames.length > 0 ? (
<small>{conflict.instanceNames.join('、')}</small>
) : null}
</li>
))}
</ul>
) : null}
<p className="identity-fingerprints">
<span title={identity.fingerprint}> {tail(identity.fingerprint)}</span>
<span title={identity.confirmedFingerprint}>
{tail(identity.confirmedFingerprint)}
</span>
<span> {timeText(identity.observedAt)}</span>
</p>
<div className="identity-actions">
<button
type="button"
disabled={busyId !== null}
onClick={() =>
void run(identity.instanceId, async () => {
const result = await dataSource.refresh(identity.instanceId);
if (mounted.current)
setNotice(
result.observed
? `${identity.instanceName || identity.instanceId} 已重新核对。`
: `${identity.instanceName || identity.instanceId} 未返回身份信息。`,
);
})
}
>
{busyId === identity.instanceId ? '正在处理...' : '重新核对'}
</button>
{identity.status === 'pending' ? (
<button
type="button"
className="primary-action"
disabled={busyId !== null}
onClick={() =>
void run(identity.instanceId, () => dataSource.confirm(identity.instanceId))
}
>
</button>
) : null}
</div>
</li>
))}
</ul>
)}
<p className="identity-hint">
</p>
</aside>
</div>
);
}
@@ -23,8 +23,8 @@ function snapshot(overrides: Partial<NotificationCenterSnapshot> = {}): Notifica
return {
observedAt: '2026-09-03T01:00:00.000Z',
devices: [
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] },
{ id: 'device-2', name: 'Modem B', state: 'unknown', tags: [] },
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'], groupId: 'group-lab' },
{ id: 'device-2', name: 'Modem B', state: 'unknown', tags: [], groupId: null },
],
config: {
channelCount: 1,
@@ -87,7 +87,7 @@ function dataSource(
eventType: 'sms',
enabled: true,
condition: { field: 'content', mode: 'contains', value: '余额' },
scope: { mode: 'tags', tags: ['office'], match: 'all', instanceIds: [] },
scope: { mode: 'tags', groups: [], tags: ['office'], match: 'all', instanceIds: [] },
channels: [{ id: 'ch-1', name: '值班 Bark' }],
templates: { title: '余额提醒', body: '{{content}}' },
rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 },
@@ -487,6 +487,7 @@ function RuleEditor({
const [mode, setMode] = useState(rule?.condition.mode ?? 'all');
const [value, setValue] = useState(rule?.condition.value ?? '');
const [scopeMode, setScopeMode] = useState(rule?.scope.mode ?? 'all');
const [groups, setGroups] = useState<string[]>([...(rule?.scope.groups ?? [])]);
const [tags, setTags] = useState<string[]>([...(rule?.scope.tags ?? [])]);
const [match, setMatch] = useState(rule?.scope.match ?? 'any');
const [instanceIds, setInstanceIds] = useState<string[]>([...(rule?.scope.instanceIds ?? [])]);
@@ -508,6 +509,12 @@ function RuleEditor({
for (const device of devices) for (const tag of device.tags) set.add(tag);
return [...set].sort();
}, [devices, tags]);
const knownGroups = useMemo(() => {
const selected = new Map<string, string>();
for (const group of groups) selected.set(group, group);
for (const device of devices) if (device.groupId) selected.set(device.groupId, device.groupId);
return [...selected.keys()].sort();
}, [devices, groups]);
const toggle =
(list: string[], update: (next: string[]) => void) =>
@@ -539,7 +546,7 @@ function RuleEditor({
eventType,
enabled,
condition: { field, mode, value },
scope: { mode: scopeMode, tags, match, instanceIds },
scope: { mode: scopeMode, groups, tags, match, instanceIds },
channelIds,
templates: { title, body },
rateLimit: {
@@ -636,10 +643,29 @@ function RuleEditor({
<span></span>
<select value={scopeMode} onChange={(event) => setScopeMode(event.currentTarget.value)}>
<option value="all"></option>
<option value="groups"></option>
<option value="tags"></option>
<option value="devices"></option>
</select>
</label>
{scopeMode === 'groups' ? (
<div className="fleet-notification-channels">
{knownGroups.length === 0 ? (
<Empty text="暂无分组,请先在设备管理中分配分组。" />
) : null}
{knownGroups.map((group) => (
<button
type="button"
key={group}
className="operation-chip"
aria-pressed={groups.includes(group)}
onClick={() => toggle(groups, setGroups)(group)}
>
{group}
</button>
))}
</div>
) : null}
{scopeMode === 'tags' ? (
<>
<div className="fleet-notification-channels">
@@ -1345,9 +1371,11 @@ export function FleetNotificationsPage({ dataSource }: FleetNotificationsPagePro
<td>
{rule.scope.mode === 'tags'
? `标签 ${rule.scope.tags.join('、')}`
: rule.scope.mode === 'devices'
? `指定 ${rule.scope.instanceIds.length}`
: '全部设备'}
: rule.scope.mode === 'groups'
? `分组 ${rule.scope.groups.join('、')}`
: rule.scope.mode === 'devices'
? `指定 ${rule.scope.instanceIds.length}`
: '全部设备'}
</td>
<td>{rule.channels.map((channel) => channel.name).join('、')}</td>
<td>
+194 -2
View File
@@ -2,7 +2,8 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetPage, accessMethod, type FleetSnapshot } from './fleet-page.js';
import { accessMethod } from './fleet-table-view-model.js';
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
const snapshot: FleetSnapshot = {
instances: [
@@ -202,6 +203,55 @@ describe('FleetPage card navigation', () => {
).toBe(true);
});
it('opens the Hub-style device context drawer and keeps the dashboard one click away', async () => {
const messagesDataSource = {
load: vi.fn(async () => ({
latest: {
id: 'sms-1',
direction: 'incoming',
phoneNumber: '13900139000',
content: 'status',
timestamp: '2026-07-19T08:30:00.000Z',
},
})),
};
render(<FleetPage initialData={snapshot} messagesDataSource={messagesDataSource} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
const drawer = screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' });
const focusables = Array.from(
drawer.querySelectorAll<HTMLElement>('a[href], button:not([disabled])'),
);
fireEvent.focus(focusables[0]!);
const dashboard = within(drawer).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
expect(dashboard.getAttribute('href')).toBe('/instances/alpha/overview');
expect(
within(drawer).getByRole('link', { name: '打开 Alpha modem 节点入口' }).getAttribute('href'),
).toBe('http://192.168.1.2');
expect(within(drawer).getByText('局域网接入')).toBeTruthy();
expect(
within(drawer).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
).toBe('24');
expect(await within(drawer).findByRole('region', { name: '短信状态' })).toBeTruthy();
fireEvent.click(within(drawer).getByRole('button', { name: '关闭设备上下文' }));
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
expect(document.activeElement).toBe(
within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }),
);
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
fireEvent.click(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }));
expect(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeTruthy();
fireEvent.click(
screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }).closest('.drawer-backdrop')!,
);
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
});
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
const withoutVersion: FleetSnapshot = {
...snapshot,
@@ -240,12 +290,17 @@ describe('FleetPage card navigation', () => {
const serviceRestart = within(card).getByRole('menuitem', {
name: '重启服务 Alpha modem',
});
const basebandRestart = within(card).getByRole('menuitem', {
name: '重启基带 Alpha modem',
});
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
expect(document.activeElement).toBe(messagesLink);
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
expect(document.activeElement).toBe(serviceRestart);
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
expect(document.activeElement).toBe(basebandRestart);
fireEvent.keyDown(basebandRestart, { key: 'ArrowDown' });
expect(document.activeElement).toBe(systemReboot);
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
expect(document.activeElement).toBe(messagesLink);
@@ -287,6 +342,32 @@ describe('FleetPage search and filter toolbar', () => {
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
});
it('filters devices by access method and reports the active condition', () => {
const accessSnapshot: FleetSnapshot = {
instances: [
...snapshot.instances,
{
id: 'wan',
name: 'Wan modem',
url: 'https://wan.example',
tags: [],
},
],
statuses: new Map([...snapshot.statuses, ['wan', { reachable: true, authenticated: true }]]),
};
render(<FleetPage initialData={accessSnapshot} />);
fireEvent.click(screen.getByText('更多筛选'));
fireEvent.change(screen.getByLabelText('接入方式'), {
target: { value: 'lan' },
});
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
expect(screen.queryByRole('article', { name: 'Wan modem 实例概览' })).toBeNull();
expect(screen.getByRole('button', { name: '移除筛选 接入:局域网接入' })).toBeTruthy();
});
it('filters the matrix from the device group row without moving search out of the sidebar', () => {
const groupedSnapshot: FleetSnapshot = {
instances: [
@@ -382,6 +463,14 @@ describe('FleetPage batch and restart actions', () => {
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
},
{
operationId: 'postBasebandRestart',
title: 'Restart Baseband',
risk: 'R3',
capability: 'job',
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
},
{
operationId: 'postSystemReboot',
title: 'Reboot System',
@@ -391,7 +480,7 @@ describe('FleetPage batch and restart actions', () => {
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
},
],
page: { page: 1, pageSize: 100, total: 2 },
page: { page: 1, pageSize: 100, total: 3 },
}));
vi.stubGlobal('confirm', () => true);
render(
@@ -406,7 +495,21 @@ describe('FleetPage batch and restart actions', () => {
});
expect(messages.getAttribute('href')).toBe('/fleet/messages?device=alpha');
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
fireEvent.click(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' }));
await vi.waitFor(() =>
expect(prepare).toHaveBeenCalledWith(
expect.objectContaining({
operationId: 'postBasebandRestart',
parameters: expect.objectContaining({
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
fields: [],
}),
}),
),
);
expect(execute).toHaveBeenCalledWith('prep-1');
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
expect(
@@ -537,4 +640,93 @@ describe('FleetPage heartbeat reporting', () => {
expect(row?.querySelector('time')).toBeNull();
expect(row?.textContent).toContain('未上报');
});
const withIdentity = (identity: Record<string, unknown> | undefined): FleetSnapshot => ({
instances: snapshot.instances,
statuses: new Map([
[
'alpha',
{
...snapshot.statuses.get('alpha')!,
...(identity ? { identity } : {}),
},
],
]),
});
it('warns on the card that the identity guard is holding the node', () => {
render(
<FleetPage
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
/>,
);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('身份待确认');
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('控制操作已暂停');
});
it('stays quiet for a confirmed node and for one that never reported identity', () => {
render(
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
);
expect(
screen
.getByRole('article', { name: 'Alpha modem 实例概览' })
.querySelector('.fleet-card-identity'),
).toBeNull();
cleanup();
render(<FleetPage initialData={withIdentity(undefined)} />);
expect(
screen
.getByRole('article', { name: 'Alpha modem 实例概览' })
.querySelector('.fleet-card-identity'),
).toBeNull();
});
it('opens the identity guard from the node toolbar', async () => {
const identityDataSource = {
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
confirm: vi.fn(),
refresh: vi.fn(),
};
render(
<FleetPage initialData={withIdentity(undefined)} identityDataSource={identityDataSource} />,
);
fireEvent.click(screen.getByRole('button', { name: '设备身份核对' }));
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
expect(identityDataSource.list).toHaveBeenCalled();
});
it('counts held nodes in the cross-health rail and opens the guard from there', async () => {
const identityDataSource = {
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
confirm: vi.fn(),
refresh: vi.fn(),
};
render(
<FleetPage
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
identityDataSource={identityDataSource}
/>,
);
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
expect(rail.textContent).toContain('身份待确认');
expect(rail.querySelector('strong')?.textContent).toBe('1');
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('attention');
fireEvent.click(rail);
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
});
it('shows a clean identity rail when every node matches its record', () => {
render(
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
);
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
expect(rail.querySelector('strong')?.textContent).toBe('0');
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('ok');
});
});
+427 -68
View File
@@ -2,15 +2,20 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEv
import { Button, Card, Input, Progress, Tag } from 'animal-island-ui';
import {
accessMethod,
buildFleetTableViewModel,
canonicalHttpOrigin,
fleetStatusKind,
type FleetAuthFilter,
type FleetAccessFilter,
type FleetFilter,
type FleetInstance,
type FleetSortColumn,
type FleetStatus,
type SortDirection,
} from './fleet-table-view-model.js';
export { accessMethod, canonicalHttpOrigin };
import type {
FleetMessageLoadState,
FleetMessagesDataSource,
@@ -19,6 +24,11 @@ import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
import { formatPhoneNumbers } from './phone-privacy.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { FleetOrganizationPanel } from './fleet-organization-panel.js';
import { FleetIdentityPanel } from './fleet-identity-panel.js';
import {
createIdentityApiDataSource,
type IdentityDataSource,
} from './identity-api-data-source.js';
import {
createOrganizationApiDataSource,
type OrganizationDataSource,
@@ -39,6 +49,8 @@ export interface FleetPageProps {
readonly dataSource?: FleetDataSource;
readonly messagesDataSource?: FleetMessagesDataSource;
readonly organizationDataSource?: OrganizationDataSource;
/** Device identity guard surface; defaults to the control-plane routes. */
readonly identityDataSource?: IdentityDataSource;
/** Groups owned by the shell; the panel mutates them and asks the shell to reload. */
readonly groups?: readonly OrganizationGroup[];
readonly onGroupsChanged?: () => void;
@@ -161,57 +173,10 @@ function heartbeatAge(value: string, now: number): string {
return `${Math.floor(seconds / 86400)} 天前`;
}
const PRIVATE_NETWORKS = [
/^10\./u,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u,
/^127\./u,
/^169\.254\./u,
/^172\.(1[6-9]|2\d|3[01])\./u,
/^192\.168\./u,
/^(?:fe80|fc|fd)/u,
];
/**
* How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is
* already implied by the origin, so it is derived rather than stored.
*/
export function accessMethod(
value: string,
): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null {
const origin = canonicalHttpOrigin(value);
if (!origin) return null;
let host: string | null;
try {
host = new URL(origin).hostname;
} catch {
return null;
}
if (!host) return null;
const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase();
if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare))
return { label: '本机直连', kind: 'local' };
if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare))
return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare))
? { label: '局域网接入', kind: 'lan' }
: { label: '公网地址', kind: 'wan' };
return { label: '域名接入', kind: 'wan' };
}
export function canonicalHttpOrigin(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
url.username = '';
url.password = '';
return url.origin;
} catch {
return null;
}
}
type BatchActionKind = 'service-restart' | 'system-reboot';
type CardActionKind = BatchActionKind | 'baseband-restart';
type BatchProgress = Readonly<{
action: BatchActionKind;
action: CardActionKind;
total: number;
completed: number;
succeeded: number;
@@ -224,6 +189,29 @@ const SERVICE_RESTART = {
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
title: '重启服务',
} as const;
const BASEBAND_RESTART = {
operationId: 'postBasebandRestart',
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
title: '重启基带',
} as const;
const CONTEXT_MODULES: ReadonlyArray<{
module: string;
label: string;
icon: IconName;
}> = [
{ module: 'overview', label: '状态与资源', icon: 'grid' },
{ module: 'cellular', label: '蜂窝面板', icon: 'activity' },
{ module: 'sim', label: 'SIM 面板', icon: 'server' },
{ module: 'device-network', label: '设备网络', icon: 'wifi' },
{ module: 'messages', label: '短信中心', icon: 'message' },
{ module: 'calls', label: '通话', icon: 'phone' },
{ module: 'esim', label: 'eSIM', icon: 'server' },
{ module: 'notifications', label: '通知', icon: 'alert' },
{ module: 'automation', label: '自动化', icon: 'restart' },
{ module: 'ota', label: 'OTA', icon: 'version' },
];
const SYSTEM_REBOOT = {
operationId: 'postSystemReboot',
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
@@ -235,6 +223,7 @@ export function FleetPage({
dataSource,
messagesDataSource,
organizationDataSource,
identityDataSource,
groups: controlledGroups,
initialData,
refreshSignal = 0,
@@ -248,6 +237,7 @@ export function FleetPage({
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<FleetFilter>('all');
const [auth, setAuth] = useState<FleetAuthFilter>('all');
const [access, setAccess] = useState<FleetAccessFilter>('all');
const [capability, setCapability] = useState('');
const [version, setVersion] = useState('');
const [tag, setTag] = useState('');
@@ -255,6 +245,9 @@ export function FleetPage({
const [ownGroups, setOwnGroups] = useState<readonly OrganizationGroup[]>([]);
const [organizationRevision, setOrganizationRevision] = useState(0);
const [organizationOpen, setOrganizationOpen] = useState(false);
const [identityOpen, setIdentityOpen] = useState(false);
const [contextId, setContextId] = useState<string>();
const contextTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
const [page, setPage] = useState(1);
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
column: 'name',
@@ -276,6 +269,7 @@ export function FleetPage({
const cardMenuInitialFocus = useRef<'first' | 'last'>('first');
const cardMenuPanelRef = useRef<HTMLDivElement | null>(null);
const cardMenuTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
const deviceContextPanelRef = useRef<HTMLElement | null>(null);
const messagesOwner = useRef(0);
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
new Map(),
@@ -288,8 +282,13 @@ export function FleetPage({
() => organizationDataSource ?? createOrganizationApiDataSource(),
[organizationDataSource],
);
const resolvedIdentityDataSource = useMemo(
() => identityDataSource ?? createIdentityApiDataSource(),
[identityDataSource],
);
const groups = controlledGroups ?? ownGroups;
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
const [identityImeis, setIdentityImeis] = useState<ReadonlyMap<string, string>>(new Map());
useEffect(() => {
// The shell owns the shared group registry; standalone usage loads its own copy.
@@ -311,6 +310,30 @@ export function FleetPage({
};
}, [controlledGroups, resolvedOrganizationDataSource, organizationRevision]);
useEffect(() => {
const controller = new AbortController();
let active = true;
void resolvedIdentityDataSource
.list(controller.signal)
.then((items) => {
if (!active) return;
setIdentityImeis(
new Map(
items.items
.filter((item) => item.imei)
.map((item) => [item.instanceId, item.imei] as const),
),
);
})
.catch(() => {
/* IMEI search is enrichment; the fleet list stays usable if identity is unavailable. */
});
return () => {
active = false;
controller.abort();
};
}, [resolvedIdentityDataSource]);
useEffect(() => {
if (initialData && refreshSignal === 0) {
setSnapshot(initialData);
@@ -389,6 +412,18 @@ export function FleetPage({
return () => document.removeEventListener('pointerdown', dismissFromOutside);
}, [cardMenuId]);
useEffect(() => {
if (!contextId) return;
const dismissWithKeyboard = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeContext(true);
}
};
document.addEventListener('keydown', dismissWithKeyboard);
return () => document.removeEventListener('keydown', dismissWithKeyboard);
}, [contextId]);
const closeCardMenu = (id: string, restoreFocus = false) => {
if (restoreFocus) cardMenuTriggerRefs.current.get(id)?.focus();
setCardMenuId(undefined);
@@ -424,25 +459,51 @@ export function FleetPage({
const model = useMemo(
() =>
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
query,
filter,
auth,
...(capability ? { capability } : {}),
...(version ? { version } : {}),
...(tag ? { tag } : {}),
...(group ? { group } : {}),
groupNames,
sort,
selectedIds,
page,
}),
buildFleetTableViewModel(
snapshot?.instances ?? [],
new Map(
[...(snapshot?.statuses ?? new Map())].map(([id, status]) => [
id,
identityImeis.has(id)
? ({
...status,
...(status.identity
? { identity: { ...status.identity, imei: identityImeis.get(id) } }
: {
identity: {
tracked: Boolean(identityImeis.get(id)),
status: 'confirmed',
reasons: [],
imei: identityImeis.get(id),
},
}),
} satisfies FleetStatus)
: status,
]),
),
{
query,
filter,
auth,
access,
...(capability ? { capability } : {}),
...(version ? { version } : {}),
...(tag ? { tag } : {}),
...(group ? { group } : {}),
groupNames,
sort,
selectedIds,
page,
},
),
[
auth,
capability,
access,
filter,
group,
groupNames,
identityImeis,
page,
query,
selectedIds,
@@ -482,6 +543,15 @@ export function FleetPage({
() => [...messageStates.values()].filter((state) => state.unavailable).length,
[messageStates],
);
// Nodes the identity guard is holding: their controls are greyed out, so the fleet rail has to
// say so instead of letting the operator wonder why a reachable device refuses a command.
const identityPendingCount = useMemo(
() =>
[...(snapshot?.statuses.values() ?? [])].filter(
(status) => status.identity?.tracked === true && status.identity.status === 'pending',
).length,
[snapshot],
);
const [notificationHealth, setNotificationHealth] = useState<{
pending: number;
@@ -582,6 +652,18 @@ export function FleetPage({
},
});
}
if (access !== 'all') {
chips.push({
key: 'access',
label: `接入:${
access === 'local' ? '本机直连' : access === 'lan' ? '局域网接入' : '公网地址'
}`,
onClear: () => {
setAccess('all');
setPage(1);
},
});
}
if (capability) {
chips.push({
key: 'capability',
@@ -623,16 +705,17 @@ export function FleetPage({
});
}
return chips;
}, [auth, capability, filter, group, groupNames, query, tag, version]);
}, [access, auth, capability, filter, group, groupNames, query, tag, version]);
const advancedFilterCount = useMemo(
() =>
(auth !== 'all' ? 1 : 0) +
(access !== 'all' ? 1 : 0) +
(capability ? 1 : 0) +
(version ? 1 : 0) +
(tag ? 1 : 0) +
(group ? 1 : 0),
[auth, capability, group, tag, version],
[access, auth, capability, group, tag, version],
);
useEffect(() => {
@@ -648,6 +731,7 @@ export function FleetPage({
setQuery('');
setFilter('all');
setAuth('all');
setAccess('all');
setCapability('');
setVersion('');
setTag('');
@@ -692,10 +776,15 @@ export function FleetPage({
}
async function runOperation(
kind: BatchActionKind,
kind: CardActionKind,
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
): Promise<{ succeeded: number; failed: number }> {
const op = kind === 'service-restart' ? SERVICE_RESTART : SYSTEM_REBOOT;
const op =
kind === 'service-restart'
? SERVICE_RESTART
: kind === 'baseband-restart'
? BASEBAND_RESTART
: SYSTEM_REBOOT;
// Full catalog sorts by operationId and truncates at pageSize 100; search for the exact id.
const catalog = await resolvedOperationClient.list({
search: op.operationId,
@@ -802,13 +891,18 @@ export function FleetPage({
}
}
async function runCardAction(id: string, kind: BatchActionKind): Promise<void> {
async function runCardAction(id: string, kind: CardActionKind): Promise<void> {
const instance = snapshot?.instances.find((item) => item.id === id);
if (!instance?.revision || instance.revision < 1) {
setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' });
return;
}
const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title;
const title =
kind === 'service-restart'
? SERVICE_RESTART.title
: kind === 'baseband-restart'
? BASEBAND_RESTART.title
: SYSTEM_REBOOT.title;
if (
typeof window !== 'undefined' &&
!window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`)
@@ -832,6 +926,12 @@ export function FleetPage({
}
}
const closeContext = (restoreFocus = false): void => {
const id = contextId;
setContextId(undefined);
if (restoreFocus && id) contextTriggerRefs.current.get(id)?.focus();
};
const selectFilter = (
label: string,
value: string,
@@ -856,6 +956,16 @@ export function FleetPage({
</label>
);
const contextInstance = snapshot?.instances.find((item) => item.id === contextId);
const contextStatus = contextId ? snapshot?.statuses.get(contextId) : undefined;
const contextRow = model.rows.find((row) => row.id === contextId);
const contextResources = contextStatus?.summary?.resources;
const contextPhoneNumbers = contextResources?.phoneNumbers ?? [];
const contextLatestMessage = contextId ? messageStates.get(contextId)?.latest : undefined;
const contextModules = (contextRow?.capabilities ?? [])
.map((module) => CONTEXT_MODULES.find((item) => item.module === module))
.filter((item): item is (typeof CONTEXT_MODULES)[number] => Boolean(item));
return (
<section className="fleet-panel" aria-labelledby="fleet-title">
<div className="fleet-heading">
@@ -974,6 +1084,20 @@ export function FleetPage({
: '--'}
</strong>
</a>
<button
type="button"
className="fleet-cross-health-item"
aria-label="设备身份待确认节点"
onClick={() => setIdentityOpen(true)}
>
<span>
<Icon name="shield" />
</span>
<strong data-tone={identityPendingCount > 0 ? 'attention' : 'ok'}>
{identityPendingCount}
</strong>
</button>
</section>
{selectionMode ? (
<div className="batch-entry" role="region" aria-label="批量操作入口">
@@ -1096,6 +1220,20 @@ export function FleetPage({
<option value="unknown"></option>
</select>
</label>
<label>
<span></span>
<select
value={access}
onChange={(event) =>
resetPage(() => setAccess(event.currentTarget.value as FleetAccessFilter))
}
>
<option value="all"></option>
<option value="local"></option>
<option value="lan"></option>
<option value="wan"></option>
</select>
</label>
{selectFilter(
'能力',
capability,
@@ -1152,6 +1290,15 @@ export function FleetPage({
<Icon name="tag" />
</button>
<button
type="button"
className="fleet-group-manage"
aria-label="设备身份核对"
onClick={() => setIdentityOpen(true)}
>
<Icon name="shield" />
</button>
</div>
<div className="fleet-results-header">
<div>
@@ -1282,6 +1429,19 @@ export function FleetPage({
<span className="status-dot" aria-hidden="true" />
{STATUS_LABELS[row.statusKind]}
</Tag>
<button
ref={(element) => {
if (element) contextTriggerRefs.current.set(row.id, element);
else contextTriggerRefs.current.delete(row.id);
}}
type="button"
className="fleet-card-context-trigger"
aria-label={`打开 ${row.displayName} 设备上下文`}
title="设备上下文"
onClick={() => setContextId(row.id)}
>
<Icon name="grid" />
</button>
{selectionMode ? (
<label className="fleet-card-select touch-target">
<input
@@ -1357,6 +1517,20 @@ export function FleetPage({
<Icon name="restart" />
<span></span>
</button>
<button
type="button"
role="menuitem"
tabIndex={-1}
aria-label={`重启基带 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'baseband-restart');
}}
>
<Icon name="restart" />
<span></span>
</button>
<button
type="button"
role="menuitem"
@@ -1453,6 +1627,18 @@ export function FleetPage({
{row.anomalies.join(', ')}
</p>
) : null}
{row.identityPending ? (
<p
className="fleet-card-identity"
aria-label={`${row.displayName} 身份待确认`}
>
<Tag size="small" color="app-red" variant="soft">
<Icon name="shield" />
</Tag>
<small></small>
</p>
) : null}
{row.tags.length > 0 ? (
<div className="fleet-instance-tags" aria-label="实例标签">
{row.tags.map((item) => (
@@ -1712,6 +1898,179 @@ export function FleetPage({
}}
/>
) : null}
{identityOpen ? (
<FleetIdentityPanel
dataSource={resolvedIdentityDataSource}
onClose={() => setIdentityOpen(false)}
onChanged={() => setAttempt((value) => value + 1)}
/>
) : null}
{contextInstance ? (
<div className="drawer-backdrop device-context-backdrop" onClick={() => closeContext(true)}>
<aside
ref={deviceContextPanelRef}
className="schedule-drawer device-context-drawer"
role="dialog"
aria-label={`${contextRow?.displayName ?? contextInstance.name} 设备上下文`}
onClick={(event) => event.stopPropagation()}
>
<header>
<div>
<p className="device-context-eyebrow">
<Icon name="server" />
</p>
<h2>{contextRow?.displayName ?? contextInstance.name}</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="关闭设备上下文"
onClick={() => closeContext(true)}
>
<Icon name="close" />
</button>
</header>
<div className="device-context-content">
<section className="device-context-section" aria-label="连接与接入">
<dl>
<div>
<dt></dt>
<dd>{STATUS_LABELS[fleetStatusKind(contextStatus)]}</dd>
</div>
<div>
<dt></dt>
<dd>{accessMethod(contextInstance.url)?.label ?? '未知'}</dd>
</div>
<div>
<dt></dt>
<dd>{contextRow?.version ?? '未知'}</dd>
</div>
<div>
<dt></dt>
<dd>
{(() => {
const origin = canonicalHttpOrigin(contextInstance.url);
return origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${contextRow?.displayName ?? contextInstance.name} 节点入口`}
>
{origin}
</a>
) : (
'无效'
);
})()}
</dd>
</div>
</dl>
</section>
<section className="device-context-section" aria-label="设备与 SIM">
<dl>
<div>
<dt></dt>
<dd>
{contextPhoneNumbers.length
? formatPhoneNumbers(contextPhoneNumbers, sensitive)
: '暂未获取'}
</dd>
</div>
<div>
<dt></dt>
<dd>{contextResources?.carrier ?? '未知'}</dd>
</div>
<div>
<dt></dt>
<dd>{contextResources?.accessTechnology ?? '未知'}</dd>
</div>
<div>
<dt></dt>
<dd>{registrationLabel(contextResources?.cellularRegistration)}</dd>
</div>
</dl>
</section>
<section className="device-context-section" aria-label="系统资源">
<dl>
<div>
<dt></dt>
<dd>{formatUptime(contextResources?.uptimeSeconds)}</dd>
</div>
<div>
<dt></dt>
<dd>{temperature(contextResources?.maxTemperatureCelsius)}</dd>
</div>
<div>
<dt>CPU</dt>
<dd>
{contextResources?.cpuPercent === undefined ? (
'--'
) : (
<div className="fleet-resource-meter">
<Progress
percent={contextResources.cpuPercent}
size="small"
showInfo={false}
duration={0}
aria-label="CPU 使用率"
/>
<strong>{percent(contextResources.cpuPercent)}</strong>
</div>
)}
</dd>
</div>
<div>
<dt></dt>
<dd>{percent(contextResources?.memoryPercent)}</dd>
</div>
</dl>
</section>
<section className="device-context-section" aria-label="短信状态">
{contextLatestMessage ? (
<p>
{messageDirection(contextLatestMessage.direction)} ·{' '}
{formatPhoneNumbers([contextLatestMessage.phoneNumber], sensitive)}
<time dateTime={contextLatestMessage.timestamp}>
{messageTime(contextLatestMessage.timestamp)}
</time>
</p>
) : (
<p>
{messageStates.get(contextInstance.id)?.unavailable
? '短信暂不可用'
: '暂无短信'}
</p>
)}
</section>
<section className="device-context-actions" aria-label="功能入口">
<a
className="device-context-primary"
href={`/instances/${encodeURIComponent(contextInstance.id)}/overview`}
aria-label={`打开 ${contextRow?.displayName ?? contextInstance.name} 实例仪表盘`}
>
<Icon name="grid" />
</a>
{contextModules.map((item) => (
<a
key={item.module}
href={`/instances/${encodeURIComponent(contextInstance.id)}/${item.module}`}
>
<Icon name={item.icon} />
{item.label}
</a>
))}
</section>
</div>
</aside>
</div>
) : null}
</section>
);
}
@@ -277,4 +277,74 @@ describe('fleet table view model', () => {
}).emptyReason,
).toBe('filter');
});
it('marks and searches the nodes the identity guard is holding', () => {
const instances: FleetInstance[] = [
{ id: 'held', name: 'Held', url: 'http://a', tags: [] },
{ id: 'clear', name: 'Clear', url: 'http://b', tags: [] },
{ id: 'blind', name: 'Blind', url: 'http://c', tags: [] },
];
const statuses = new Map<string, FleetStatus>([
[
'held',
{
reachable: true,
identity: { tracked: true, status: 'pending', reasons: ['imei_claimed'] },
},
],
['clear', { reachable: true, identity: { tracked: true, status: 'confirmed', reasons: [] } }],
[
'blind',
{ reachable: true, identity: { tracked: false, status: 'confirmed', reasons: [] } },
],
]);
const rows = buildFleetTableViewModel(instances, statuses).rows;
expect(rows.map((row) => [row.id, row.identityPending])).toEqual([
['blind', false],
['clear', false],
['held', true],
]);
expect(
buildFleetTableViewModel(instances, statuses, { query: '身份待确认' }).rows.map((r) => r.id),
).toEqual(['held']);
});
it('filters and searches by derived access method and reported IMEI', () => {
const scoped: readonly FleetInstance[] = [
{ id: 'local', name: 'Local', url: 'http://127.0.0.1:3000', tags: [] },
{ id: 'lan', name: 'Lan', url: 'http://192.168.1.3', tags: [] },
{ id: 'wan', name: 'Wan', url: 'https://wan.example', tags: [] },
];
const model = buildFleetTableViewModel(scoped, new Map(), { access: 'local' });
expect(model.rows.map((row) => row.id)).toEqual(['local']);
expect(buildFleetTableViewModel(instances, statuses, { access: 'lan' }).emptyReason).toBe(
'filter',
);
expect(
buildFleetTableViewModel(scoped, new Map(), { query: 'lan' }).rows.map((r) => r.id),
).toEqual(['lan']);
const identified = new Map<string, FleetStatus>([
[
'alpha',
{
reachable: true,
identity: {
tracked: true,
status: 'confirmed',
reasons: [],
imei: '860000000000001',
},
},
],
]);
expect(
buildFleetTableViewModel(
[{ id: 'alpha', name: 'Alpha', url: 'http://alpha.example', tags: [] }],
identified,
{ query: '860000000000001' },
).rows.map((row) => row.id),
).toEqual(['alpha']);
});
});
@@ -1,6 +1,7 @@
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
export type FleetFilter = 'all' | FleetStatusKind;
export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown';
export type FleetAccessFilter = 'all' | 'local' | 'lan' | 'wan';
export type FleetSortColumn =
| 'name'
| 'status'
@@ -34,6 +35,8 @@ export interface FleetStatus {
/** When the heartbeat last checked this device; null means it has never run. */
readonly checkedAt?: string | null;
readonly latencyMs?: number;
/** Device identity guard verdict for this node, from the fleet overview. */
readonly identity?: FleetIdentityState;
readonly summary?: Readonly<
Record<string, unknown> & {
resources?: Readonly<{
@@ -60,6 +63,7 @@ export interface FleetTableOptions {
readonly query?: string;
readonly filter?: FleetFilter;
readonly auth?: FleetAuthFilter;
readonly access?: FleetAccessFilter;
readonly capability?: string;
readonly version?: string;
readonly tag?: string;
@@ -74,6 +78,15 @@ export interface FleetTableOptions {
export type FleetFreshness = 'fresh' | 'stale' | 'unknown';
export interface FleetIdentityState {
/** False when the node has never reported hardware identity; nothing to confirm yet. */
readonly tracked: boolean;
readonly status: 'confirmed' | 'pending';
readonly reasons: readonly string[];
/** Filter/search only; display stays masked through the console-wide sensitive-data switch. */
readonly imei?: string;
}
export interface FleetTableRow {
readonly id: string;
readonly displayName: string;
@@ -88,6 +101,10 @@ export interface FleetTableRow {
readonly groupId: string | null;
readonly freshness: FleetFreshness;
readonly anomalies: readonly string[];
/** The identity guard is holding this node until an operator confirms the device. */
readonly identityPending: boolean;
readonly accessKind: 'local' | 'lan' | 'wan' | null;
readonly accessLabel: string | null;
}
export interface VisibleSelection {
@@ -136,6 +153,54 @@ function displayName(instance: FleetInstance): string {
return name ? name : instance.id;
}
const PRIVATE_NETWORKS = [
/^10\./u,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u,
/^127\./u,
/^169\.254\./u,
/^172\.(1[6-9]|2\d|3[01])\./u,
/^192\.168\./u,
/^(?:fe80|fc|fd)/u,
];
/**
* How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is
* already implied by the origin, so it is derived rather than stored.
*/
export function accessMethod(
value: string,
): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null {
const origin = canonicalHttpOrigin(value);
if (!origin) return null;
let host: string | null;
try {
host = new URL(origin).hostname;
} catch {
return null;
}
if (!host) return null;
const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase();
if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare))
return { label: '本机直连', kind: 'local' };
if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare))
return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare))
? { label: '局域网接入', kind: 'lan' }
: { label: '公网地址', kind: 'wan' };
return { label: '域名接入', kind: 'wan' };
}
export function canonicalHttpOrigin(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
url.username = '';
url.password = '';
return url.origin;
} catch {
return null;
}
}
function summaryString(status: FleetStatus | undefined, key: string): string | null {
const value = status?.summary?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -150,6 +215,7 @@ function summaryStrings(status: FleetStatus | undefined, key: string): readonly
}
function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
const access = accessMethod(instance.url);
const freshnessValue = summaryString(status, 'freshness');
const freshness: FleetFreshness =
freshnessValue === 'fresh' || freshnessValue === 'stale' ? freshnessValue : 'unknown';
@@ -161,6 +227,10 @@ function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
typeof instance.groupId === 'string' && instance.groupId.trim() ? instance.groupId : null,
freshness,
anomalies: summaryStrings(status, 'anomalies'),
identityPending: status?.identity?.tracked === true && status.identity.status === 'pending',
...(access
? { accessKind: access.kind, accessLabel: access.label }
: { accessKind: null, accessLabel: null }),
};
}
@@ -198,8 +268,12 @@ function searchableText(
groupName,
details.version ?? '',
...details.capabilities,
status?.identity?.imei ?? '',
details.accessLabel ?? '',
details.accessKind ?? '',
details.freshness,
...details.anomalies,
details.identityPending ? '身份待确认' : '',
status?.summary === undefined ? '' : JSON.stringify(status.summary),
]
.join(' ')
@@ -292,6 +366,8 @@ export function buildFleetTableViewModel(
const details = metadata(instance, status);
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
if (!authenticationMatches(status, auth)) return false;
if (options.access && options.access !== 'all' && details.accessKind !== options.access)
return false;
if (options.capability && !details.capabilities.includes(options.capability)) return false;
if (options.version && details.version !== options.version) return false;
if (options.tag && !details.tags.includes(options.tag)) return false;
@@ -323,6 +399,7 @@ export function buildFleetTableViewModel(
const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0);
const hasMetadataFilter =
auth !== 'all' ||
Boolean(options.access && options.access !== 'all') ||
Boolean(options.capability || options.version || options.tag || options.group);
const emptyReason: EmptyFleetReason | null =
instances.length === 0
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';
import { createIdentityApiDataSource } from './identity-api-data-source.js';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
const identity = {
instanceId: 'node-a',
instanceName: '机房 A',
status: 'pending',
reasons: ['imei_claimed'],
conflicts: [
{ reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] },
{ reason: 'made-up', instanceIds: ['x'], instanceNames: [] },
],
imei: '860000000000001',
manufacturer: 'Quectel',
model: 'RM500Q',
revision: '',
agent: '',
origin: '',
fingerprint: 'f'.repeat(64),
confirmedFingerprint: 'c'.repeat(64),
changes: [
{ field: 'model', from: 'RM500Q', to: 'RG500Q' },
{ field: 'unknown', from: 'a', to: 'b' },
],
observedAt: '2026-09-05T02:00:00.000Z',
confirmedAt: '2026-09-01T02:00:00.000Z',
};
describe('identity API data source', () => {
it('reads the guard view and drops entries it cannot trust', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
json({
items: [identity, { status: 'pending' }, null],
summary: { tracked: 5, pending: 2 },
}),
);
const overview = await createIdentityApiDataSource(fetcher).list();
expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/identities', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
expect(overview.tracked).toBe(5);
expect(overview.pending).toBe(2);
expect(overview.items).toHaveLength(1);
const [item] = overview.items;
expect(item?.status).toBe('pending');
expect(item?.reasons).toEqual(['imei_claimed']);
// Only the reason the server actually explains survives.
expect(item?.conflicts).toEqual([
{ reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] },
]);
expect(item?.changes).toEqual([{ field: 'model', from: 'RM500Q', to: 'RG500Q' }]);
});
it('degrades to an empty view when the payload is not shaped like a list', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ items: 'nope' }));
await expect(createIdentityApiDataSource(fetcher).list()).resolves.toEqual({
items: [],
tracked: 0,
pending: 0,
});
});
it('confirms a node through the documented route', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ identity }));
const confirmed = await createIdentityApiDataSource(fetcher).confirm('node a/1');
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/fleet/identities/node%20a%2F1/confirm',
expect.objectContaining({ method: 'POST', body: '{}' }),
);
expect(confirmed.instanceId).toBe('node-a');
});
it('reports a refresh that found no identity on the device', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ observed: false }));
await expect(createIdentityApiDataSource(fetcher).refresh('node-a')).resolves.toEqual({
observed: false,
});
});
it('fails loudly when the control plane refuses', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ code: 'NOT_FOUND' }, 404));
await expect(createIdentityApiDataSource(fetcher).confirm('ghost')).rejects.toThrow('404');
});
});
@@ -0,0 +1,186 @@
export type IdentityStatus = 'confirmed' | 'pending';
export type IdentityReason = 'hardware_swapped' | 'imei_claimed';
export type IdentityField = 'imei' | 'manufacturer' | 'model' | 'revision';
export interface IdentityConflict {
readonly reason: IdentityReason;
readonly instanceIds: readonly string[];
readonly instanceNames: readonly string[];
}
export interface IdentityChange {
readonly field: IdentityField;
readonly from: string;
readonly to: string;
}
export interface DeviceIdentity {
readonly instanceId: string;
readonly instanceName: string;
readonly status: IdentityStatus;
readonly reasons: readonly IdentityReason[];
readonly conflicts: readonly IdentityConflict[];
readonly imei: string;
readonly manufacturer: string;
readonly model: string;
readonly revision: string;
readonly agent: string;
readonly origin: string;
readonly fingerprint: string;
readonly confirmedFingerprint: string;
readonly changes: readonly IdentityChange[];
readonly observedAt: string;
readonly confirmedAt: string;
}
export interface IdentityOverview {
readonly items: readonly DeviceIdentity[];
readonly tracked: number;
readonly pending: number;
}
export interface IdentityDataSource {
list(signal?: AbortSignal): Promise<IdentityOverview>;
confirm(instanceId: string): Promise<DeviceIdentity>;
/** One device read; `observed` is false when the node answered without any identity field. */
refresh(instanceId: string): Promise<{ observed: boolean; identity?: DeviceIdentity }>;
}
const REASONS: ReadonlySet<string> = new Set(['hardware_swapped', 'imei_claimed']);
const FIELDS: ReadonlySet<string> = new Set(['imei', 'manufacturer', 'model', 'revision']);
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
const boundedText = (value: unknown, maximum: number): string => {
const raw = text(value);
return raw.length > maximum ? raw.slice(0, maximum) : raw;
};
function reasons(value: unknown): readonly IdentityReason[] {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is IdentityReason => typeof item === 'string' && REASONS.has(item),
);
}
function conflicts(value: unknown): readonly IdentityConflict[] {
if (!Array.isArray(value)) return [];
const parsed: IdentityConflict[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const item = entry as Record<string, unknown>;
const reason = text(item.reason);
if (!REASONS.has(reason)) continue;
parsed.push({
reason: reason as IdentityReason,
instanceIds: Array.isArray(item.instanceIds)
? item.instanceIds.map(text).filter(Boolean)
: [],
instanceNames: Array.isArray(item.instanceNames)
? item.instanceNames.map(text).filter(Boolean)
: [],
});
}
return parsed;
}
function changes(value: unknown): readonly IdentityChange[] {
if (!Array.isArray(value)) return [];
const parsed: IdentityChange[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const item = entry as Record<string, unknown>;
const field = text(item.field);
if (!FIELDS.has(field)) continue;
parsed.push({
field: field as IdentityField,
from: boundedText(item.from, 64),
to: boundedText(item.to, 64),
});
}
return parsed;
}
function parseIdentity(value: unknown): DeviceIdentity | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const item = value as Record<string, unknown>;
const instanceId = boundedText(item.instanceId, 256);
if (!instanceId) return undefined;
return {
instanceId,
instanceName: boundedText(item.instanceName, 200),
status: item.status === 'pending' ? 'pending' : 'confirmed',
reasons: reasons(item.reasons),
conflicts: conflicts(item.conflicts),
imei: boundedText(item.imei, 64),
manufacturer: boundedText(item.manufacturer, 128),
model: boundedText(item.model, 128),
revision: boundedText(item.revision, 128),
agent: boundedText(item.agent, 128),
origin: boundedText(item.origin, 512),
fingerprint: boundedText(item.fingerprint, 128),
confirmedFingerprint: boundedText(item.confirmedFingerprint, 128),
changes: changes(item.changes),
observedAt: boundedText(item.observedAt, 64),
confirmedAt: boundedText(item.confirmedAt, 64),
};
}
async function json<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`Identity request failed (${response.status})`);
return (await response.json()) as T;
}
const HEADERS = { accept: 'application/json' } as const;
function postJson(): RequestInit {
return {
method: 'POST',
credentials: 'same-origin',
headers: { ...HEADERS, 'content-type': 'application/json' },
body: '{}',
};
}
const path = (instanceId: string, action: string): string =>
`/api/v1/fleet/identities/${encodeURIComponent(instanceId)}/${action}`;
export function createIdentityApiDataSource(fetcher: typeof fetch = fetch): IdentityDataSource {
return {
async list(signal) {
const body = await json<unknown>(
await fetcher('/api/v1/fleet/identities', {
method: 'GET',
credentials: 'same-origin',
headers: HEADERS,
...(signal ? { signal } : {}),
}),
);
const source = (body ?? {}) as Record<string, unknown>;
const items = (Array.isArray(source.items) ? source.items : [])
.map(parseIdentity)
.filter((item): item is DeviceIdentity => item !== undefined);
const summary = (source.summary ?? {}) as Record<string, unknown>;
const count = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
return { items, tracked: count(summary.tracked), pending: count(summary.pending) };
},
async confirm(instanceId) {
const body = await json<unknown>(await fetcher(path(instanceId, 'confirm'), postJson()));
const identity = parseIdentity((body as Record<string, unknown>).identity);
if (!identity) throw new Error('Identity response is invalid.');
return identity;
},
async refresh(instanceId) {
const body = await json<unknown>(await fetcher(path(instanceId, 'refresh'), postJson()));
const source = (body ?? {}) as Record<string, unknown>;
const identity = parseIdentity(source.identity);
return {
observed: source.observed === true,
...(identity ? { identity } : {}),
};
},
};
}
@@ -18,7 +18,15 @@ describe('notification center API data source', () => {
const fetcher = vi.fn().mockResolvedValue(
json({
observedAt: '2026-09-03T01:00:00.000Z',
devices: [{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }],
devices: [
{
id: 'device-1',
name: 'Modem A',
state: 'ready',
tags: ['office'],
groupId: 'group-lab',
},
],
config: {
channelCount: 2,
channelEnabled: 1,
@@ -44,7 +52,7 @@ describe('notification center API data source', () => {
expect(fetcher).toHaveBeenCalledWith('/api/v1/notifications/overview', expect.anything());
expect(snapshot.devices).toEqual([
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] },
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'], groupId: 'group-lab' },
]);
expect(snapshot.queue.pending).toBe(2);
expect(snapshot.queue.total).toBe(5);
@@ -153,11 +153,13 @@ export function createNotificationCenterApiDataSource(
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
},
scope:
draft.scope.mode === 'tags'
? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match }
: draft.scope.mode === 'devices'
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
: { mode: 'all' },
draft.scope.mode === 'groups'
? { mode: 'groups', groups: [...draft.scope.groups] }
: draft.scope.mode === 'tags'
? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match }
: draft.scope.mode === 'devices'
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
: { mode: 'all' },
channelIds: [...draft.channelIds],
templates: { title: draft.templates.title, body: draft.templates.body },
...(draft.rateLimit === undefined ? {} : { rateLimit: draft.rateLimit }),
@@ -152,6 +152,7 @@ export function sanitizeNotificationRule(value: unknown): NotificationRule | und
}),
scope: Object.freeze({
mode: text(scope?.mode, 20) || 'all',
groups: Object.freeze(stringList(scope?.groups)),
tags: Object.freeze(stringList(scope?.tags)),
match: text(scope?.match, 20) || 'any',
instanceIds: Object.freeze(stringList(scope?.instanceIds)),
@@ -219,6 +220,7 @@ function deviceList(value: unknown): readonly NotificationDevice[] {
name: text(entry.name, 160) || '未命名设备',
state: text(entry.state, 40) || 'unavailable',
tags: Object.freeze(stringList(entry.tags)),
groupId: typeof entry.groupId === 'string' && entry.groupId ? entry.groupId : null,
}),
),
);
@@ -40,6 +40,7 @@ export interface NotificationRule {
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
readonly scope: {
readonly mode: string;
readonly groups: readonly string[];
readonly tags: readonly string[];
readonly match: string;
readonly instanceIds: readonly string[];
@@ -92,6 +93,7 @@ export interface NotificationDevice {
readonly name: string;
readonly state: string;
readonly tags: readonly string[];
readonly groupId: string | null;
}
export interface NotificationCenterSnapshot {
@@ -142,6 +144,7 @@ export interface NotificationRuleDraft {
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
readonly scope: {
readonly mode: string;
readonly groups: readonly string[];
readonly tags: readonly string[];
readonly match: string;
readonly instanceIds: readonly string[];