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[0]).toMatchObject({ probed: true, reachable: false });
|
||||||
expect(partials.at(-1)).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 { 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 =>
|
const integer = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isSafeInteger(value);
|
typeof value === 'number' && Number.isSafeInteger(value);
|
||||||
@@ -126,15 +126,36 @@ function parseConnection(value: unknown): ConnectionView {
|
|||||||
function skeletonStatuses(
|
function skeletonStatuses(
|
||||||
instances: readonly FleetInstance[],
|
instances: readonly FleetInstance[],
|
||||||
connections: readonly ConnectionView[],
|
connections: readonly ConnectionView[],
|
||||||
|
identities: readonly (FleetIdentityState | undefined)[],
|
||||||
): Map<string, FleetStatus> {
|
): Map<string, FleetStatus> {
|
||||||
return new Map(
|
return new Map(
|
||||||
instances.map((instance, index) => [
|
instances.map((instance, index) => {
|
||||||
|
const identity = identities[index];
|
||||||
|
return [
|
||||||
instance.id,
|
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 {
|
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||||
return {
|
return {
|
||||||
async load(signal, onPartial) {
|
async load(signal, onPartial) {
|
||||||
@@ -153,9 +174,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
|
|||||||
const connections = overviewBody.items.map((item) =>
|
const connections = overviewBody.items.map((item) =>
|
||||||
parseConnection(record(item)?.connection),
|
parseConnection(record(item)?.connection),
|
||||||
);
|
);
|
||||||
|
const identities = overviewBody.items.map((item) => parseIdentity(record(item)?.identity));
|
||||||
const partial: FleetSnapshot = {
|
const partial: FleetSnapshot = {
|
||||||
instances: readyInstances,
|
instances: readyInstances,
|
||||||
statuses: skeletonStatuses(readyInstances, connections),
|
statuses: skeletonStatuses(readyInstances, connections, identities),
|
||||||
};
|
};
|
||||||
onPartial?.(partial);
|
onPartial?.(partial);
|
||||||
|
|
||||||
@@ -163,8 +185,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
|
|||||||
overviewBody.items.map((item, index) => {
|
overviewBody.items.map((item, index) => {
|
||||||
const instance = readyInstances[index]!;
|
const instance = readyInstances[index]!;
|
||||||
const resources = record(item)?.resources;
|
const resources = record(item)?.resources;
|
||||||
|
const identity = identities[index];
|
||||||
const status: FleetStatus = {
|
const status: FleetStatus = {
|
||||||
...connections[index]!,
|
...connections[index]!,
|
||||||
|
...(identity ? { identity } : {}),
|
||||||
summary: resources ? parseResources(resources) : { freshness: 'unknown' },
|
summary: resources ? parseResources(resources) : { freshness: 'unknown' },
|
||||||
};
|
};
|
||||||
return [instance.id, status] as const;
|
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 {
|
return {
|
||||||
observedAt: '2026-09-03T01:00:00.000Z',
|
observedAt: '2026-09-03T01:00:00.000Z',
|
||||||
devices: [
|
devices: [
|
||||||
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] },
|
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'], groupId: 'group-lab' },
|
||||||
{ id: 'device-2', name: 'Modem B', state: 'unknown', tags: [] },
|
{ id: 'device-2', name: 'Modem B', state: 'unknown', tags: [], groupId: null },
|
||||||
],
|
],
|
||||||
config: {
|
config: {
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
@@ -87,7 +87,7 @@ function dataSource(
|
|||||||
eventType: 'sms',
|
eventType: 'sms',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
condition: { field: 'content', mode: 'contains', value: '余额' },
|
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' }],
|
channels: [{ id: 'ch-1', name: '值班 Bark' }],
|
||||||
templates: { title: '余额提醒', body: '{{content}}' },
|
templates: { title: '余额提醒', body: '{{content}}' },
|
||||||
rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 },
|
rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 },
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ function RuleEditor({
|
|||||||
const [mode, setMode] = useState(rule?.condition.mode ?? 'all');
|
const [mode, setMode] = useState(rule?.condition.mode ?? 'all');
|
||||||
const [value, setValue] = useState(rule?.condition.value ?? '');
|
const [value, setValue] = useState(rule?.condition.value ?? '');
|
||||||
const [scopeMode, setScopeMode] = useState(rule?.scope.mode ?? 'all');
|
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 [tags, setTags] = useState<string[]>([...(rule?.scope.tags ?? [])]);
|
||||||
const [match, setMatch] = useState(rule?.scope.match ?? 'any');
|
const [match, setMatch] = useState(rule?.scope.match ?? 'any');
|
||||||
const [instanceIds, setInstanceIds] = useState<string[]>([...(rule?.scope.instanceIds ?? [])]);
|
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);
|
for (const device of devices) for (const tag of device.tags) set.add(tag);
|
||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
}, [devices, tags]);
|
}, [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 =
|
const toggle =
|
||||||
(list: string[], update: (next: string[]) => void) =>
|
(list: string[], update: (next: string[]) => void) =>
|
||||||
@@ -539,7 +546,7 @@ function RuleEditor({
|
|||||||
eventType,
|
eventType,
|
||||||
enabled,
|
enabled,
|
||||||
condition: { field, mode, value },
|
condition: { field, mode, value },
|
||||||
scope: { mode: scopeMode, tags, match, instanceIds },
|
scope: { mode: scopeMode, groups, tags, match, instanceIds },
|
||||||
channelIds,
|
channelIds,
|
||||||
templates: { title, body },
|
templates: { title, body },
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
@@ -636,10 +643,29 @@ function RuleEditor({
|
|||||||
<span>范围</span>
|
<span>范围</span>
|
||||||
<select value={scopeMode} onChange={(event) => setScopeMode(event.currentTarget.value)}>
|
<select value={scopeMode} onChange={(event) => setScopeMode(event.currentTarget.value)}>
|
||||||
<option value="all">全部设备</option>
|
<option value="all">全部设备</option>
|
||||||
|
<option value="groups">按分组</option>
|
||||||
<option value="tags">按标签</option>
|
<option value="tags">按标签</option>
|
||||||
<option value="devices">指定设备</option>
|
<option value="devices">指定设备</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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' ? (
|
{scopeMode === 'tags' ? (
|
||||||
<>
|
<>
|
||||||
<div className="fleet-notification-channels">
|
<div className="fleet-notification-channels">
|
||||||
@@ -1345,6 +1371,8 @@ export function FleetNotificationsPage({ dataSource }: FleetNotificationsPagePro
|
|||||||
<td>
|
<td>
|
||||||
{rule.scope.mode === 'tags'
|
{rule.scope.mode === 'tags'
|
||||||
? `标签 ${rule.scope.tags.join('、')}`
|
? `标签 ${rule.scope.tags.join('、')}`
|
||||||
|
: rule.scope.mode === 'groups'
|
||||||
|
? `分组 ${rule.scope.groups.join('、')}`
|
||||||
: rule.scope.mode === 'devices'
|
: rule.scope.mode === 'devices'
|
||||||
? `指定 ${rule.scope.instanceIds.length} 台`
|
? `指定 ${rule.scope.instanceIds.length} 台`
|
||||||
: '全部设备'}
|
: '全部设备'}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 = {
|
const snapshot: FleetSnapshot = {
|
||||||
instances: [
|
instances: [
|
||||||
@@ -202,6 +203,55 @@ describe('FleetPage card navigation', () => {
|
|||||||
).toBe(true);
|
).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', () => {
|
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
|
||||||
const withoutVersion: FleetSnapshot = {
|
const withoutVersion: FleetSnapshot = {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
@@ -240,12 +290,17 @@ describe('FleetPage card navigation', () => {
|
|||||||
const serviceRestart = within(card).getByRole('menuitem', {
|
const serviceRestart = within(card).getByRole('menuitem', {
|
||||||
name: '重启服务 Alpha modem',
|
name: '重启服务 Alpha modem',
|
||||||
});
|
});
|
||||||
|
const basebandRestart = within(card).getByRole('menuitem', {
|
||||||
|
name: '重启基带 Alpha modem',
|
||||||
|
});
|
||||||
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
|
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
|
||||||
expect(document.activeElement).toBe(messagesLink);
|
expect(document.activeElement).toBe(messagesLink);
|
||||||
|
|
||||||
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
|
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
|
||||||
expect(document.activeElement).toBe(serviceRestart);
|
expect(document.activeElement).toBe(serviceRestart);
|
||||||
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
|
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
|
||||||
|
expect(document.activeElement).toBe(basebandRestart);
|
||||||
|
fireEvent.keyDown(basebandRestart, { key: 'ArrowDown' });
|
||||||
expect(document.activeElement).toBe(systemReboot);
|
expect(document.activeElement).toBe(systemReboot);
|
||||||
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
|
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
|
||||||
expect(document.activeElement).toBe(messagesLink);
|
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/);
|
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', () => {
|
it('filters the matrix from the device group row without moving search out of the sidebar', () => {
|
||||||
const groupedSnapshot: FleetSnapshot = {
|
const groupedSnapshot: FleetSnapshot = {
|
||||||
instances: [
|
instances: [
|
||||||
@@ -382,6 +463,14 @@ describe('FleetPage batch and restart actions', () => {
|
|||||||
batchable: false,
|
batchable: false,
|
||||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
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',
|
operationId: 'postSystemReboot',
|
||||||
title: 'Reboot System',
|
title: 'Reboot System',
|
||||||
@@ -391,7 +480,7 @@ describe('FleetPage batch and restart actions', () => {
|
|||||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
page: { page: 1, pageSize: 100, total: 2 },
|
page: { page: 1, pageSize: 100, total: 3 },
|
||||||
}));
|
}));
|
||||||
vi.stubGlobal('confirm', () => true);
|
vi.stubGlobal('confirm', () => true);
|
||||||
render(
|
render(
|
||||||
@@ -406,7 +495,21 @@ describe('FleetPage batch and restart actions', () => {
|
|||||||
});
|
});
|
||||||
expect(messages.getAttribute('href')).toBe('/fleet/messages?device=alpha');
|
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();
|
||||||
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(screen.getByRole('button', { name: '批量选择' }));
|
||||||
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
|
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
|
||||||
expect(
|
expect(
|
||||||
@@ -537,4 +640,93 @@ describe('FleetPage heartbeat reporting', () => {
|
|||||||
expect(row?.querySelector('time')).toBeNull();
|
expect(row?.querySelector('time')).toBeNull();
|
||||||
expect(row?.textContent).toContain('未上报');
|
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 { Button, Card, Input, Progress, Tag } from 'animal-island-ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
accessMethod,
|
||||||
buildFleetTableViewModel,
|
buildFleetTableViewModel,
|
||||||
|
canonicalHttpOrigin,
|
||||||
fleetStatusKind,
|
fleetStatusKind,
|
||||||
type FleetAuthFilter,
|
type FleetAuthFilter,
|
||||||
|
type FleetAccessFilter,
|
||||||
type FleetFilter,
|
type FleetFilter,
|
||||||
type FleetInstance,
|
type FleetInstance,
|
||||||
type FleetSortColumn,
|
type FleetSortColumn,
|
||||||
type FleetStatus,
|
type FleetStatus,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from './fleet-table-view-model.js';
|
} from './fleet-table-view-model.js';
|
||||||
|
|
||||||
|
export { accessMethod, canonicalHttpOrigin };
|
||||||
import type {
|
import type {
|
||||||
FleetMessageLoadState,
|
FleetMessageLoadState,
|
||||||
FleetMessagesDataSource,
|
FleetMessagesDataSource,
|
||||||
@@ -19,6 +24,11 @@ import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
|||||||
import { formatPhoneNumbers } from './phone-privacy.js';
|
import { formatPhoneNumbers } from './phone-privacy.js';
|
||||||
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
|
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
|
||||||
import { FleetOrganizationPanel } from './fleet-organization-panel.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 {
|
import {
|
||||||
createOrganizationApiDataSource,
|
createOrganizationApiDataSource,
|
||||||
type OrganizationDataSource,
|
type OrganizationDataSource,
|
||||||
@@ -39,6 +49,8 @@ export interface FleetPageProps {
|
|||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
readonly messagesDataSource?: FleetMessagesDataSource;
|
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||||
readonly organizationDataSource?: OrganizationDataSource;
|
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. */
|
/** Groups owned by the shell; the panel mutates them and asks the shell to reload. */
|
||||||
readonly groups?: readonly OrganizationGroup[];
|
readonly groups?: readonly OrganizationGroup[];
|
||||||
readonly onGroupsChanged?: () => void;
|
readonly onGroupsChanged?: () => void;
|
||||||
@@ -161,57 +173,10 @@ function heartbeatAge(value: string, now: number): string {
|
|||||||
return `${Math.floor(seconds / 86400)} 天前`;
|
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 BatchActionKind = 'service-restart' | 'system-reboot';
|
||||||
|
type CardActionKind = BatchActionKind | 'baseband-restart';
|
||||||
type BatchProgress = Readonly<{
|
type BatchProgress = Readonly<{
|
||||||
action: BatchActionKind;
|
action: CardActionKind;
|
||||||
total: number;
|
total: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
@@ -224,6 +189,29 @@ const SERVICE_RESTART = {
|
|||||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||||
title: '重启服务',
|
title: '重启服务',
|
||||||
} as const;
|
} 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 = {
|
const SYSTEM_REBOOT = {
|
||||||
operationId: 'postSystemReboot',
|
operationId: 'postSystemReboot',
|
||||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||||
@@ -235,6 +223,7 @@ export function FleetPage({
|
|||||||
dataSource,
|
dataSource,
|
||||||
messagesDataSource,
|
messagesDataSource,
|
||||||
organizationDataSource,
|
organizationDataSource,
|
||||||
|
identityDataSource,
|
||||||
groups: controlledGroups,
|
groups: controlledGroups,
|
||||||
initialData,
|
initialData,
|
||||||
refreshSignal = 0,
|
refreshSignal = 0,
|
||||||
@@ -248,6 +237,7 @@ export function FleetPage({
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [filter, setFilter] = useState<FleetFilter>('all');
|
const [filter, setFilter] = useState<FleetFilter>('all');
|
||||||
const [auth, setAuth] = useState<FleetAuthFilter>('all');
|
const [auth, setAuth] = useState<FleetAuthFilter>('all');
|
||||||
|
const [access, setAccess] = useState<FleetAccessFilter>('all');
|
||||||
const [capability, setCapability] = useState('');
|
const [capability, setCapability] = useState('');
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [tag, setTag] = useState('');
|
const [tag, setTag] = useState('');
|
||||||
@@ -255,6 +245,9 @@ export function FleetPage({
|
|||||||
const [ownGroups, setOwnGroups] = useState<readonly OrganizationGroup[]>([]);
|
const [ownGroups, setOwnGroups] = useState<readonly OrganizationGroup[]>([]);
|
||||||
const [organizationRevision, setOrganizationRevision] = useState(0);
|
const [organizationRevision, setOrganizationRevision] = useState(0);
|
||||||
const [organizationOpen, setOrganizationOpen] = useState(false);
|
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 [page, setPage] = useState(1);
|
||||||
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
||||||
column: 'name',
|
column: 'name',
|
||||||
@@ -276,6 +269,7 @@ export function FleetPage({
|
|||||||
const cardMenuInitialFocus = useRef<'first' | 'last'>('first');
|
const cardMenuInitialFocus = useRef<'first' | 'last'>('first');
|
||||||
const cardMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
const cardMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const cardMenuTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
const cardMenuTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const deviceContextPanelRef = useRef<HTMLElement | null>(null);
|
||||||
const messagesOwner = useRef(0);
|
const messagesOwner = useRef(0);
|
||||||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -288,8 +282,13 @@ export function FleetPage({
|
|||||||
() => organizationDataSource ?? createOrganizationApiDataSource(),
|
() => organizationDataSource ?? createOrganizationApiDataSource(),
|
||||||
[organizationDataSource],
|
[organizationDataSource],
|
||||||
);
|
);
|
||||||
|
const resolvedIdentityDataSource = useMemo(
|
||||||
|
() => identityDataSource ?? createIdentityApiDataSource(),
|
||||||
|
[identityDataSource],
|
||||||
|
);
|
||||||
const groups = controlledGroups ?? ownGroups;
|
const groups = controlledGroups ?? ownGroups;
|
||||||
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
|
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
|
||||||
|
const [identityImeis, setIdentityImeis] = useState<ReadonlyMap<string, string>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The shell owns the shared group registry; standalone usage loads its own copy.
|
// The shell owns the shared group registry; standalone usage loads its own copy.
|
||||||
@@ -311,6 +310,30 @@ export function FleetPage({
|
|||||||
};
|
};
|
||||||
}, [controlledGroups, resolvedOrganizationDataSource, organizationRevision]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (initialData && refreshSignal === 0) {
|
if (initialData && refreshSignal === 0) {
|
||||||
setSnapshot(initialData);
|
setSnapshot(initialData);
|
||||||
@@ -389,6 +412,18 @@ export function FleetPage({
|
|||||||
return () => document.removeEventListener('pointerdown', dismissFromOutside);
|
return () => document.removeEventListener('pointerdown', dismissFromOutside);
|
||||||
}, [cardMenuId]);
|
}, [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) => {
|
const closeCardMenu = (id: string, restoreFocus = false) => {
|
||||||
if (restoreFocus) cardMenuTriggerRefs.current.get(id)?.focus();
|
if (restoreFocus) cardMenuTriggerRefs.current.get(id)?.focus();
|
||||||
setCardMenuId(undefined);
|
setCardMenuId(undefined);
|
||||||
@@ -424,10 +459,33 @@ export function FleetPage({
|
|||||||
|
|
||||||
const model = useMemo(
|
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,
|
query,
|
||||||
filter,
|
filter,
|
||||||
auth,
|
auth,
|
||||||
|
access,
|
||||||
...(capability ? { capability } : {}),
|
...(capability ? { capability } : {}),
|
||||||
...(version ? { version } : {}),
|
...(version ? { version } : {}),
|
||||||
...(tag ? { tag } : {}),
|
...(tag ? { tag } : {}),
|
||||||
@@ -436,13 +494,16 @@ export function FleetPage({
|
|||||||
sort,
|
sort,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
page,
|
page,
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
[
|
[
|
||||||
auth,
|
auth,
|
||||||
capability,
|
capability,
|
||||||
|
access,
|
||||||
filter,
|
filter,
|
||||||
group,
|
group,
|
||||||
groupNames,
|
groupNames,
|
||||||
|
identityImeis,
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -482,6 +543,15 @@ export function FleetPage({
|
|||||||
() => [...messageStates.values()].filter((state) => state.unavailable).length,
|
() => [...messageStates.values()].filter((state) => state.unavailable).length,
|
||||||
[messageStates],
|
[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<{
|
const [notificationHealth, setNotificationHealth] = useState<{
|
||||||
pending: number;
|
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) {
|
if (capability) {
|
||||||
chips.push({
|
chips.push({
|
||||||
key: 'capability',
|
key: 'capability',
|
||||||
@@ -623,16 +705,17 @@ export function FleetPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return chips;
|
return chips;
|
||||||
}, [auth, capability, filter, group, groupNames, query, tag, version]);
|
}, [access, auth, capability, filter, group, groupNames, query, tag, version]);
|
||||||
|
|
||||||
const advancedFilterCount = useMemo(
|
const advancedFilterCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(auth !== 'all' ? 1 : 0) +
|
(auth !== 'all' ? 1 : 0) +
|
||||||
|
(access !== 'all' ? 1 : 0) +
|
||||||
(capability ? 1 : 0) +
|
(capability ? 1 : 0) +
|
||||||
(version ? 1 : 0) +
|
(version ? 1 : 0) +
|
||||||
(tag ? 1 : 0) +
|
(tag ? 1 : 0) +
|
||||||
(group ? 1 : 0),
|
(group ? 1 : 0),
|
||||||
[auth, capability, group, tag, version],
|
[access, auth, capability, group, tag, version],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -648,6 +731,7 @@ export function FleetPage({
|
|||||||
setQuery('');
|
setQuery('');
|
||||||
setFilter('all');
|
setFilter('all');
|
||||||
setAuth('all');
|
setAuth('all');
|
||||||
|
setAccess('all');
|
||||||
setCapability('');
|
setCapability('');
|
||||||
setVersion('');
|
setVersion('');
|
||||||
setTag('');
|
setTag('');
|
||||||
@@ -692,10 +776,15 @@ export function FleetPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runOperation(
|
async function runOperation(
|
||||||
kind: BatchActionKind,
|
kind: CardActionKind,
|
||||||
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
|
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
|
||||||
): Promise<{ succeeded: number; failed: 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.
|
// Full catalog sorts by operationId and truncates at pageSize 100; search for the exact id.
|
||||||
const catalog = await resolvedOperationClient.list({
|
const catalog = await resolvedOperationClient.list({
|
||||||
search: op.operationId,
|
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);
|
const instance = snapshot?.instances.find((item) => item.id === id);
|
||||||
if (!instance?.revision || instance.revision < 1) {
|
if (!instance?.revision || instance.revision < 1) {
|
||||||
setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' });
|
setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' });
|
||||||
return;
|
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 (
|
if (
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
!window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`)
|
!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 = (
|
const selectFilter = (
|
||||||
label: string,
|
label: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -856,6 +956,16 @@ export function FleetPage({
|
|||||||
</label>
|
</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 (
|
return (
|
||||||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||||||
<div className="fleet-heading">
|
<div className="fleet-heading">
|
||||||
@@ -974,6 +1084,20 @@ export function FleetPage({
|
|||||||
: '--'}
|
: '--'}
|
||||||
</strong>
|
</strong>
|
||||||
</a>
|
</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>
|
</section>
|
||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||||
@@ -1096,6 +1220,20 @@ export function FleetPage({
|
|||||||
<option value="unknown">未知</option>
|
<option value="unknown">未知</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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(
|
{selectFilter(
|
||||||
'能力',
|
'能力',
|
||||||
capability,
|
capability,
|
||||||
@@ -1152,6 +1290,15 @@ export function FleetPage({
|
|||||||
<Icon name="tag" />
|
<Icon name="tag" />
|
||||||
管理分组与标签
|
管理分组与标签
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fleet-group-manage"
|
||||||
|
aria-label="设备身份核对"
|
||||||
|
onClick={() => setIdentityOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="shield" />
|
||||||
|
设备身份
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="fleet-results-header">
|
<div className="fleet-results-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1282,6 +1429,19 @@ export function FleetPage({
|
|||||||
<span className="status-dot" aria-hidden="true" />
|
<span className="status-dot" aria-hidden="true" />
|
||||||
{STATUS_LABELS[row.statusKind]}
|
{STATUS_LABELS[row.statusKind]}
|
||||||
</Tag>
|
</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 ? (
|
{selectionMode ? (
|
||||||
<label className="fleet-card-select touch-target">
|
<label className="fleet-card-select touch-target">
|
||||||
<input
|
<input
|
||||||
@@ -1357,6 +1517,20 @@ export function FleetPage({
|
|||||||
<Icon name="restart" />
|
<Icon name="restart" />
|
||||||
<span>重启服务</span>
|
<span>重启服务</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -1453,6 +1627,18 @@ export function FleetPage({
|
|||||||
{row.anomalies.join(', ')}
|
{row.anomalies.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{row.tags.length > 0 ? (
|
||||||
<div className="fleet-instance-tags" aria-label="实例标签">
|
<div className="fleet-instance-tags" aria-label="实例标签">
|
||||||
{row.tags.map((item) => (
|
{row.tags.map((item) => (
|
||||||
@@ -1712,6 +1898,179 @@ export function FleetPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,4 +277,74 @@ describe('fleet table view model', () => {
|
|||||||
}).emptyReason,
|
}).emptyReason,
|
||||||
).toBe('filter');
|
).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 FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
||||||
export type FleetFilter = 'all' | FleetStatusKind;
|
export type FleetFilter = 'all' | FleetStatusKind;
|
||||||
export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown';
|
export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown';
|
||||||
|
export type FleetAccessFilter = 'all' | 'local' | 'lan' | 'wan';
|
||||||
export type FleetSortColumn =
|
export type FleetSortColumn =
|
||||||
| 'name'
|
| 'name'
|
||||||
| 'status'
|
| 'status'
|
||||||
@@ -34,6 +35,8 @@ export interface FleetStatus {
|
|||||||
/** When the heartbeat last checked this device; null means it has never run. */
|
/** When the heartbeat last checked this device; null means it has never run. */
|
||||||
readonly checkedAt?: string | null;
|
readonly checkedAt?: string | null;
|
||||||
readonly latencyMs?: number;
|
readonly latencyMs?: number;
|
||||||
|
/** Device identity guard verdict for this node, from the fleet overview. */
|
||||||
|
readonly identity?: FleetIdentityState;
|
||||||
readonly summary?: Readonly<
|
readonly summary?: Readonly<
|
||||||
Record<string, unknown> & {
|
Record<string, unknown> & {
|
||||||
resources?: Readonly<{
|
resources?: Readonly<{
|
||||||
@@ -60,6 +63,7 @@ export interface FleetTableOptions {
|
|||||||
readonly query?: string;
|
readonly query?: string;
|
||||||
readonly filter?: FleetFilter;
|
readonly filter?: FleetFilter;
|
||||||
readonly auth?: FleetAuthFilter;
|
readonly auth?: FleetAuthFilter;
|
||||||
|
readonly access?: FleetAccessFilter;
|
||||||
readonly capability?: string;
|
readonly capability?: string;
|
||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
readonly tag?: string;
|
readonly tag?: string;
|
||||||
@@ -74,6 +78,15 @@ export interface FleetTableOptions {
|
|||||||
|
|
||||||
export type FleetFreshness = 'fresh' | 'stale' | 'unknown';
|
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 {
|
export interface FleetTableRow {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
@@ -88,6 +101,10 @@ export interface FleetTableRow {
|
|||||||
readonly groupId: string | null;
|
readonly groupId: string | null;
|
||||||
readonly freshness: FleetFreshness;
|
readonly freshness: FleetFreshness;
|
||||||
readonly anomalies: readonly string[];
|
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 {
|
export interface VisibleSelection {
|
||||||
@@ -136,6 +153,54 @@ function displayName(instance: FleetInstance): string {
|
|||||||
return name ? name : instance.id;
|
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 {
|
function summaryString(status: FleetStatus | undefined, key: string): string | null {
|
||||||
const value = status?.summary?.[key];
|
const value = status?.summary?.[key];
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
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) {
|
function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
|
||||||
|
const access = accessMethod(instance.url);
|
||||||
const freshnessValue = summaryString(status, 'freshness');
|
const freshnessValue = summaryString(status, 'freshness');
|
||||||
const freshness: FleetFreshness =
|
const freshness: FleetFreshness =
|
||||||
freshnessValue === 'fresh' || freshnessValue === 'stale' ? freshnessValue : 'unknown';
|
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,
|
typeof instance.groupId === 'string' && instance.groupId.trim() ? instance.groupId : null,
|
||||||
freshness,
|
freshness,
|
||||||
anomalies: summaryStrings(status, 'anomalies'),
|
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,
|
groupName,
|
||||||
details.version ?? '',
|
details.version ?? '',
|
||||||
...details.capabilities,
|
...details.capabilities,
|
||||||
|
status?.identity?.imei ?? '',
|
||||||
|
details.accessLabel ?? '',
|
||||||
|
details.accessKind ?? '',
|
||||||
details.freshness,
|
details.freshness,
|
||||||
...details.anomalies,
|
...details.anomalies,
|
||||||
|
details.identityPending ? '身份待确认' : '',
|
||||||
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -292,6 +366,8 @@ export function buildFleetTableViewModel(
|
|||||||
const details = metadata(instance, status);
|
const details = metadata(instance, status);
|
||||||
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
||||||
if (!authenticationMatches(status, auth)) 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.capability && !details.capabilities.includes(options.capability)) return false;
|
||||||
if (options.version && details.version !== options.version) return false;
|
if (options.version && details.version !== options.version) return false;
|
||||||
if (options.tag && !details.tags.includes(options.tag)) 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 visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0);
|
||||||
const hasMetadataFilter =
|
const hasMetadataFilter =
|
||||||
auth !== 'all' ||
|
auth !== 'all' ||
|
||||||
|
Boolean(options.access && options.access !== 'all') ||
|
||||||
Boolean(options.capability || options.version || options.tag || options.group);
|
Boolean(options.capability || options.version || options.tag || options.group);
|
||||||
const emptyReason: EmptyFleetReason | null =
|
const emptyReason: EmptyFleetReason | null =
|
||||||
instances.length === 0
|
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(
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
json({
|
json({
|
||||||
observedAt: '2026-09-03T01:00:00.000Z',
|
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: {
|
config: {
|
||||||
channelCount: 2,
|
channelCount: 2,
|
||||||
channelEnabled: 1,
|
channelEnabled: 1,
|
||||||
@@ -44,7 +52,7 @@ describe('notification center API data source', () => {
|
|||||||
|
|
||||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/notifications/overview', expect.anything());
|
expect(fetcher).toHaveBeenCalledWith('/api/v1/notifications/overview', expect.anything());
|
||||||
expect(snapshot.devices).toEqual([
|
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.pending).toBe(2);
|
||||||
expect(snapshot.queue.total).toBe(5);
|
expect(snapshot.queue.total).toBe(5);
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ export function createNotificationCenterApiDataSource(
|
|||||||
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
|
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
|
||||||
},
|
},
|
||||||
scope:
|
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 }
|
? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match }
|
||||||
: draft.scope.mode === 'devices'
|
: draft.scope.mode === 'devices'
|
||||||
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
|
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export function sanitizeNotificationRule(value: unknown): NotificationRule | und
|
|||||||
}),
|
}),
|
||||||
scope: Object.freeze({
|
scope: Object.freeze({
|
||||||
mode: text(scope?.mode, 20) || 'all',
|
mode: text(scope?.mode, 20) || 'all',
|
||||||
|
groups: Object.freeze(stringList(scope?.groups)),
|
||||||
tags: Object.freeze(stringList(scope?.tags)),
|
tags: Object.freeze(stringList(scope?.tags)),
|
||||||
match: text(scope?.match, 20) || 'any',
|
match: text(scope?.match, 20) || 'any',
|
||||||
instanceIds: Object.freeze(stringList(scope?.instanceIds)),
|
instanceIds: Object.freeze(stringList(scope?.instanceIds)),
|
||||||
@@ -219,6 +220,7 @@ function deviceList(value: unknown): readonly NotificationDevice[] {
|
|||||||
name: text(entry.name, 160) || '未命名设备',
|
name: text(entry.name, 160) || '未命名设备',
|
||||||
state: text(entry.state, 40) || 'unavailable',
|
state: text(entry.state, 40) || 'unavailable',
|
||||||
tags: Object.freeze(stringList(entry.tags)),
|
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 condition: { readonly field: string; readonly mode: string; readonly value: string };
|
||||||
readonly scope: {
|
readonly scope: {
|
||||||
readonly mode: string;
|
readonly mode: string;
|
||||||
|
readonly groups: readonly string[];
|
||||||
readonly tags: readonly string[];
|
readonly tags: readonly string[];
|
||||||
readonly match: string;
|
readonly match: string;
|
||||||
readonly instanceIds: readonly string[];
|
readonly instanceIds: readonly string[];
|
||||||
@@ -92,6 +93,7 @@ export interface NotificationDevice {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly state: string;
|
readonly state: string;
|
||||||
readonly tags: readonly string[];
|
readonly tags: readonly string[];
|
||||||
|
readonly groupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationCenterSnapshot {
|
export interface NotificationCenterSnapshot {
|
||||||
@@ -142,6 +144,7 @@ export interface NotificationRuleDraft {
|
|||||||
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
|
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
|
||||||
readonly scope: {
|
readonly scope: {
|
||||||
readonly mode: string;
|
readonly mode: string;
|
||||||
|
readonly groups: readonly string[];
|
||||||
readonly tags: readonly string[];
|
readonly tags: readonly string[];
|
||||||
readonly match: string;
|
readonly match: string;
|
||||||
readonly instanceIds: readonly string[];
|
readonly instanceIds: readonly string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user