feat(web): prioritize SMS operations workspace
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createFleetMessagesApiDataSource,
|
||||
loadFleetMessageSummaries,
|
||||
type FleetMessagesDataSource,
|
||||
} from './fleet-messages-api-data-source.js';
|
||||
|
||||
describe('Fleet messages API data source', () => {
|
||||
it('loads only the newest message through the same-origin read endpoint', async () => {
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
id: 'sms-1',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '13800138000',
|
||||
content: '聚合页最新短信',
|
||||
timestamp: '2026-07-19T08:30:00.000Z',
|
||||
status: 'received',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
createFleetMessagesApiDataSource(fetcher as typeof fetch).load('alpha'),
|
||||
).resolves.toEqual({
|
||||
latest: {
|
||||
id: 'sms-1',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '13800138000',
|
||||
content: '聚合页最新短信',
|
||||
timestamp: '2026-07-19T08:30:00.000Z',
|
||||
},
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/instances/alpha/messages?limit=1&offset=0',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty summary and rejects malformed responses', async () => {
|
||||
const emptyFetcher = vi.fn(async () => new Response(JSON.stringify({ messages: [] })));
|
||||
await expect(
|
||||
createFleetMessagesApiDataSource(emptyFetcher as typeof fetch).load('alpha'),
|
||||
).resolves.toEqual({});
|
||||
|
||||
const malformedFetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] })));
|
||||
await expect(
|
||||
createFleetMessagesApiDataSource(malformedFetcher as typeof fetch).load('alpha'),
|
||||
).rejects.toThrow('Fleet messages response is invalid.');
|
||||
});
|
||||
|
||||
it('bounds fleet fan-out and stops scheduling new owners after abort', async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const releases: (() => void)[] = [];
|
||||
const load = vi.fn<FleetMessagesDataSource['load']>(async (instanceId, signal) => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
active -= 1;
|
||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
return {
|
||||
latest: {
|
||||
id: instanceId,
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: instanceId,
|
||||
timestamp: '2026-07-19T08:30:00.000Z',
|
||||
},
|
||||
};
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = loadFleetMessageSummaries(
|
||||
{ load },
|
||||
['one', 'two', 'three', 'four', 'five', 'six'],
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(4));
|
||||
expect(peak).toBe(4);
|
||||
controller.abort();
|
||||
for (const release of releases) release();
|
||||
await expect(pending).resolves.toEqual(new Map());
|
||||
expect(load).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface FleetMessageSummary {
|
||||
readonly id: string;
|
||||
readonly direction: string;
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface FleetMessagesSnapshot {
|
||||
readonly latest?: FleetMessageSummary;
|
||||
}
|
||||
|
||||
export interface FleetMessagesDataSource {
|
||||
load(instanceId: string, signal?: AbortSignal): Promise<FleetMessagesSnapshot>;
|
||||
}
|
||||
|
||||
export type FleetMessageLoadState = Readonly<{
|
||||
latest?: FleetMessageSummary;
|
||||
unavailable?: true;
|
||||
}>;
|
||||
|
||||
const FLEET_MESSAGE_CONCURRENCY = 4;
|
||||
export async function loadFleetMessageSummaries(
|
||||
dataSource: FleetMessagesDataSource,
|
||||
instanceIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<ReadonlyMap<string, FleetMessageLoadState>> {
|
||||
const results = new Map<string, FleetMessageLoadState>();
|
||||
let cursor = 0;
|
||||
async function worker(): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
const index = cursor++;
|
||||
const instanceId = instanceIds[index];
|
||||
if (instanceId === undefined) return;
|
||||
try {
|
||||
const result = await dataSource.load(instanceId, signal);
|
||||
if (signal.aborted) return;
|
||||
results.set(instanceId, result.latest ? { latest: result.latest } : {});
|
||||
} catch {
|
||||
if (signal.aborted) return;
|
||||
results.set(instanceId, { unavailable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(FLEET_MESSAGE_CONCURRENCY, instanceIds.length) }, () => worker()),
|
||||
);
|
||||
return signal.aborted ? new Map() : results;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseMessage(value: unknown): FleetMessageSummary | undefined {
|
||||
const item = record(value);
|
||||
if (
|
||||
typeof item?.id !== 'string' ||
|
||||
typeof item.direction !== 'string' ||
|
||||
typeof item.phoneNumber !== 'string' ||
|
||||
typeof item.content !== 'string' ||
|
||||
typeof item.timestamp !== 'string'
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
direction: item.direction,
|
||||
phoneNumber: item.phoneNumber,
|
||||
content: item.content,
|
||||
timestamp: item.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFleetMessagesApiDataSource(
|
||||
fetcher: typeof fetch = fetch,
|
||||
): FleetMessagesDataSource {
|
||||
return {
|
||||
async load(instanceId, signal): Promise<FleetMessagesSnapshot> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=1&offset=0`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Fleet messages request failed (${response.status}).`);
|
||||
const root = record(await response.json());
|
||||
if (!Array.isArray(root?.messages)) throw new Error('Fleet messages response is invalid.');
|
||||
if (root.messages.length === 0) return {};
|
||||
const latest = parseMessage(root.messages[0]);
|
||||
if (!latest) throw new Error('Fleet messages response is invalid.');
|
||||
return { latest };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type SortDirection,
|
||||
} from './fleet-table-view-model.js';
|
||||
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
||||
import type {
|
||||
FleetMessageLoadState,
|
||||
FleetMessagesDataSource,
|
||||
} from './fleet-messages-api-data-source.js';
|
||||
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||||
|
||||
export interface FleetSnapshot {
|
||||
readonly instances: readonly FleetInstance[];
|
||||
@@ -20,10 +25,12 @@ export interface FleetDataSource {
|
||||
}
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||
readonly instanceDataSource?: InstanceDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
type FleetMessageState = FleetMessageLoadState;
|
||||
|
||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
@@ -69,6 +76,18 @@ const percent = (value: number | undefined): string =>
|
||||
value === undefined ? '暂未获取' : `${value.toFixed(1)}%`;
|
||||
const temperature = (value: number | undefined): string =>
|
||||
value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`;
|
||||
const messageDirection = (value: string): string =>
|
||||
value === 'incoming' || value === 'received'
|
||||
? '收到'
|
||||
: value === 'outgoing' || value === 'sent'
|
||||
? '发送'
|
||||
: '未知方向';
|
||||
const messageExcerpt = (value: string): string =>
|
||||
value.length > 36 ? `${value.slice(0, 36)}…` : value;
|
||||
const messageTime = (value: string): string => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
export function canonicalHttpOrigin(value: string): string | null {
|
||||
try {
|
||||
@@ -84,6 +103,7 @@ export function canonicalHttpOrigin(value: string): string | null {
|
||||
|
||||
export function FleetPage({
|
||||
dataSource,
|
||||
messagesDataSource,
|
||||
instanceDataSource,
|
||||
initialData,
|
||||
refreshSignal = 0,
|
||||
@@ -114,6 +134,10 @@ export function FleetPage({
|
||||
const [deleteStatus, setDeleteStatus] =
|
||||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
const messagesOwner = useRef(0);
|
||||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData && refreshSignal === 0) {
|
||||
@@ -141,6 +165,23 @@ export function FleetPage({
|
||||
};
|
||||
}, [attempt, dataSource, initialData, refreshSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++messagesOwner.current;
|
||||
const controller = new AbortController();
|
||||
const instances = snapshot?.instances;
|
||||
setMessageStates(new Map<string, FleetMessageState>());
|
||||
if (!messagesDataSource || !instances) return () => controller.abort();
|
||||
void loadFleetMessageSummaries(
|
||||
messagesDataSource,
|
||||
instances.map((instance) => instance.id),
|
||||
controller.signal,
|
||||
).then((entries) => {
|
||||
if (request === messagesOwner.current && !controller.signal.aborted)
|
||||
setMessageStates(entries);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [messagesDataSource, snapshot]);
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||
@@ -445,21 +486,47 @@ export function FleetPage({
|
||||
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="capability-tags" aria-label="能力">
|
||||
{row.capabilities.length > 0 ? (
|
||||
row.capabilities.map((item) => (
|
||||
{row.capabilities.length > 0 ? (
|
||||
<div className="capability-tags" aria-label="能力">
|
||||
{row.capabilities.map((item) => (
|
||||
<span key={item}>{capabilityLabel(item)}</span>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<section className="fleet-card-sms" aria-label="最近短信">
|
||||
<h3>最近短信</h3>
|
||||
{messageStates.get(row.id)?.unavailable ? (
|
||||
<p className="fleet-card-sms-empty">短信暂不可用</p>
|
||||
) : messageStates.get(row.id)?.latest ? (
|
||||
<dl>
|
||||
<div>
|
||||
<dt>方向</dt>
|
||||
<dd>{messageDirection(messageStates.get(row.id)!.latest!.direction)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>号码</dt>
|
||||
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内容</dt>
|
||||
<dd>{messageExcerpt(messageStates.get(row.id)!.latest!.content)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>时间</dt>
|
||||
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<span>能力未知</span>
|
||||
<p className="fleet-card-sms-empty">暂无短信</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="fleet-card-actions">
|
||||
<a
|
||||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||||
aria-label={`管理 ${row.displayName}`}
|
||||
className="primary-action"
|
||||
href={`/instances/${encodeURIComponent(row.id)}/messages`}
|
||||
aria-label={`查看 ${row.displayName} 的短信`}
|
||||
>
|
||||
管理
|
||||
查看短信
|
||||
</a>
|
||||
<a
|
||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||
|
||||
Reference in New Issue
Block a user