feat(web): prioritize SMS operations workspace

This commit is contained in:
chick
2026-07-19 10:30:52 +08:00
parent 0112e3320a
commit f7d69a0fa7
10 changed files with 532 additions and 68 deletions
@@ -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 };
},
};
}
+76 -9
View File
@@ -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)}`}