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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => [
|
||||
instances.map((instance, index) => {
|
||||
const identity = identities[index];
|
||||
return [
|
||||
instance.id,
|
||||
{ ...(connections[index] ?? unprobed), summary: { freshness: 'unknown' } },
|
||||
]),
|
||||
{
|
||||
...(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);
|
||||
});
|
||||
});
|
||||
@@ -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,6 +1371,8 @@ export function FleetNotificationsPage({ dataSource }: FleetNotificationsPagePro
|
||||
<td>
|
||||
{rule.scope.mode === 'tags'
|
||||
? `标签 ${rule.scope.tags.join('、')}`
|
||||
: rule.scope.mode === 'groups'
|
||||
? `分组 ${rule.scope.groups.join('、')}`
|
||||
: rule.scope.mode === 'devices'
|
||||
? `指定 ${rule.scope.instanceIds.length} 台`
|
||||
: '全部设备'}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,10 +459,33 @@ export function FleetPage({
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||
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 } : {}),
|
||||
@@ -436,13 +494,16 @@ export function FleetPage({
|
||||
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,7 +153,9 @@ export function createNotificationCenterApiDataSource(
|
||||
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
|
||||
},
|
||||
scope:
|
||||
draft.scope.mode === 'tags'
|
||||
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] }
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user