feat(sms): add real instance message workflow

This commit is contained in:
chick
2026-07-19 02:12:57 +08:00
parent 38a26c2ce1
commit 0112e3320a
17 changed files with 781 additions and 450 deletions
@@ -6,6 +6,8 @@ export interface UpstreamRequest {
readonly secret?: string; readonly secret?: string;
/** Redacted representation suitable for observability, never the password. */ /** Redacted representation suitable for observability, never the password. */
readonly body?: '[REDACTED]'; readonly body?: '[REDACTED]';
/** Explicit one-shot SMS action payload. Implementations must not log or persist it. */
readonly sms?: { readonly phoneNumber: string; readonly content: string };
} }
export interface UpstreamResponse { export interface UpstreamResponse {
readonly status: number; readonly status: number;
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import { InstanceSessionStore } from '../connections/upstream-session-client.js';
import {
InstanceMessageService,
MessageServiceError,
parseMessageList,
} from './instance-message-service.js';
const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' };
const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) };
describe('InstanceMessageService', () => {
it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => {
const messages = parseMessageList(
{
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 'm1',
direction: 'received',
phone_number: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
pdu: 'SECRET-PDU',
transport: 'modem',
password: 'secret',
},
],
},
}),
},
10,
);
expect(messages).toEqual([
{
id: 'm1',
direction: 'received',
phoneNumber: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
transport: 'modem',
},
]);
expect(JSON.stringify(messages)).not.toContain('PDU');
expect(JSON.stringify(messages)).not.toContain('secret');
});
it('uses exact owner, bounded query and matching optional session cookie', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', instance.origin, 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","data":{"messages":[]}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(
service.list('alpha', { limit: 20, offset: 2, direction: 'outgoing' }),
).resolves.toEqual({ messages: [] });
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/list?limit=20&offset=2&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' },
});
});
it('sends once with an explicit payload and returns only safe success', async () => {
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","message":"queued","data":{"token":"secret"}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions: new InstanceSessionStore(),
request,
});
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).resolves.toEqual({ sent: true });
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
});
it('fails closed for missing owners, stale sessions and upstream status:error', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"error","message":"recipient secret"}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(service.list('alpha', { limit: 10, offset: 0 })).rejects.toBeInstanceOf(
MessageServiceError,
);
await expect(service.list('missing', { limit: 10, offset: 0 })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
sessions.clear('alpha');
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).rejects.toMatchObject({ code: 'UPSTREAM_FAILED' });
});
});
@@ -0,0 +1,191 @@
import type { InstanceService } from '../instances/instance-service.js';
import type {
InstanceSessionStore,
UpstreamResponse,
UpstreamSessionClientOptions,
} from '../connections/upstream-session-client.js';
export type MessageDirection = 'received' | 'sent' | 'incoming' | 'outgoing' | 'unknown';
export interface InstanceMessage {
readonly id: string;
readonly direction: MessageDirection;
readonly phoneNumber: string;
readonly content: string;
readonly timestamp: string;
readonly status: string;
readonly transport: string;
}
export interface MessageListQuery {
readonly limit: number;
readonly offset: number;
readonly direction?: Exclude<MessageDirection, 'unknown'>;
}
export interface SendMessageInput {
readonly phoneNumber: string;
readonly content: string;
}
export type MessageServiceErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_FAILED'
| 'SESSION_INVALID'
| 'UPSTREAM_FAILED';
export class MessageServiceError extends Error {
constructor(readonly code: MessageServiceErrorCode) {
super(code);
this.name = 'MessageServiceError';
}
}
const MAX_RESPONSE_BYTES = 262_144;
const DIRECTIONS = new Set<MessageDirection>(['received', 'sent', 'incoming', 'outgoing']);
const record = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const bounded = (value: unknown, maximum: number): string | undefined =>
(typeof value === 'string' || typeof value === 'number') &&
String(value).length <= maximum &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value))
? String(value)
: undefined;
export function validPhoneNumber(value: string): boolean {
return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value);
}
export function validMessageContent(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 1600 &&
Buffer.byteLength(value, 'utf8') <= 6400 &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
);
}
export function parseMessageList(
response: UpstreamResponse,
limit: number,
): readonly InstanceMessage[] {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES
)
throw new MessageServiceError('UPSTREAM_FAILED');
let root: Record<string, unknown> | undefined;
try {
root = record(JSON.parse(response.body));
} catch {
throw new MessageServiceError('UPSTREAM_FAILED');
}
if (root?.status !== 'success' && root?.status !== 'ok')
throw new MessageServiceError('UPSTREAM_FAILED');
const source = record(root.data)?.messages;
if (!Array.isArray(source) || source.length > limit)
throw new MessageServiceError('UPSTREAM_FAILED');
const output: InstanceMessage[] = [];
for (const item of source) {
const value = record(item);
const id = bounded(value?.id, 128);
const phoneNumber = bounded(value?.phone_number, 32);
const content = bounded(value?.content, 1600);
const timestamp = bounded(value?.timestamp, 64);
const status = bounded(value?.status, 32);
const transport = bounded(value?.transport, 32);
const rawDirection = bounded(value?.direction, 16);
if (
!id ||
!phoneNumber ||
!validPhoneNumber(phoneNumber) ||
content === undefined ||
timestamp === undefined ||
status === undefined ||
transport === undefined
)
continue;
const direction: MessageDirection =
rawDirection && DIRECTIONS.has(rawDirection as MessageDirection)
? (rawDirection as MessageDirection)
: 'unknown';
output.push({ id, direction, phoneNumber, content, timestamp, status, transport });
}
return output;
}
function parseSendSuccess(response: UpstreamResponse): void {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > 32_768
)
throw new MessageServiceError('UPSTREAM_FAILED');
try {
if (record(JSON.parse(response.body))?.status !== 'success')
throw new MessageServiceError('UPSTREAM_FAILED');
} catch (error) {
if (error instanceof MessageServiceError) throw error;
throw new MessageServiceError('UPSTREAM_FAILED');
}
}
export class InstanceMessageService {
constructor(
private readonly options: {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
},
) {}
private async owner(instanceId: string) {
const instance = await this.options.instances.get(instanceId);
if (!instance) throw new MessageServiceError('NOT_FOUND');
const session = this.options.sessions.sessionFor(instanceId);
if (session && session.origin !== instance.origin)
throw new MessageServiceError('SESSION_INVALID');
return { instance, session };
}
async list(
instanceId: string,
query: MessageListQuery,
): Promise<{ readonly messages: readonly InstanceMessage[] }> {
if (
!Number.isSafeInteger(query.limit) ||
query.limit < 1 ||
query.limit > 100 ||
!Number.isSafeInteger(query.offset) ||
query.offset < 0 ||
query.offset > 10_000 ||
(query.direction !== undefined &&
query.direction !== 'incoming' &&
query.direction !== 'outgoing')
)
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const direction = query.direction ? `&direction=${query.direction}` : '';
const response = await this.options.request({
url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`,
method: 'GET',
headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) },
});
return { messages: parseMessageList(response, query.limit) };
}
async send(instanceId: string, input: SendMessageInput): Promise<{ readonly sent: true }> {
if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content))
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const response = await this.options.request({
url: `${instance.origin}/api/sms/send`,
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(session ? { cookie: session.cookie } : {}),
},
sms: { phoneNumber: input.phoneNumber, content: input.content },
});
parseSendSuccess(response);
return { sent: true };
}
}
+7
View File
@@ -29,6 +29,7 @@ import { registerJobRoutes } from './interface/http/job-routes.js';
import { AuditQueryService } from './application/audit/audit-query-service.js'; import { AuditQueryService } from './application/audit/audit-query-service.js';
import { registerAuditRoutes } from './interface/http/audit-routes.js'; import { registerAuditRoutes } from './interface/http/audit-routes.js';
import { InstanceResourceService } from './application/resources/instance-resource-service.js'; import { InstanceResourceService } from './application/resources/instance-resource-service.js';
import { InstanceMessageService } from './application/messages/instance-message-service.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport { export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request']; request: UpstreamSessionClientOptions['request'];
@@ -67,6 +68,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions, sessions,
request: options.upstream.request, request: options.upstream.request,
}); });
const messages = new InstanceMessageService({
instances,
sessions,
request: options.upstream.request,
});
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store }); const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
const login = options.now const login = options.now
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now }) ? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
@@ -96,6 +102,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
login, login,
deletion, deletion,
resources, resources,
messages,
registerDeletionPreparationRoute: false, registerDeletionPreparationRoute: false,
}); });
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
@@ -59,7 +59,6 @@ const origin = (raw: string): URL => {
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username || parsed.username ||
parsed.password || parsed.password ||
parsed.search ||
parsed.hash parsed.hash
) )
throw new TransportError('UNSAFE_ORIGIN'); throw new TransportError('UNSAFE_ORIGIN');
@@ -98,6 +98,53 @@ describe('SafeUpstreamGateway', () => {
}); });
}); });
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json' },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
expect(get).toHaveBeenCalledOnce();
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/sms/send',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"phone_number":"+15550199","content":"hello"}',
);
});
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
for (const url of [
'http://192.168.1.20/api/sms/list?limit=101&offset=0',
'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true',
'http://192.168.1.20/api/sms/list?offset=0&limit=10',
])
await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
await expect(
gateway.request({
url: 'http://192.168.1.20/api/sms/send',
method: 'POST',
headers: {},
sms: { phoneNumber: 'bad\nnumber', content: 'hello' },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(get).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});
it('does not allow a supplied redacted body marker to become a network request body', async () => { it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({ const gateway = new SafeUpstreamGateway({
transport: { transport: {
@@ -46,12 +46,21 @@ export class SafeUpstreamGateway {
} }
async request(request: UpstreamRequest): Promise<UpstreamResponse> { async request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url); const url = new URL(request.url);
const headerKeys = Object.keys(request.headers).sort().join(',');
if (request.method === 'GET') { if (request.method === 'GET') {
const smsList = url.pathname === '/api/sms/list';
const smsQuery = smsList
? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec(
url.search.slice(1),
)
: null;
if ( if (
request.secret !== undefined || request.secret !== undefined ||
request.body !== undefined || request.body !== undefined ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') || request.sms !== undefined ||
url.search || (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(!smsList && url.search) ||
url.hash || url.hash ||
url.username || url.username ||
url.password || url.password ||
@@ -62,6 +71,31 @@ export class SafeUpstreamGateway {
throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.get(request.url, request.headers); return this.options.transport.get(request.url, request.headers);
} }
if (url.pathname === '/api/sms/send') {
const sms = request.sms;
if (
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
!sms ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
sms.content.length < 1 ||
sms.content.length > 1600 ||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
url.username ||
url.password
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH'); if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) { if (request.url.endsWith('/api/auth/login')) {
@@ -20,6 +20,10 @@ import {
DeleteInstanceOperationError, DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js'; } from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js'; import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import {
MessageServiceError,
type InstanceMessageService,
} from '../../application/messages/instance-message-service.js';
export interface InstanceRoutesOptions { export interface InstanceRoutesOptions {
readonly instances: InstanceService; readonly instances: InstanceService;
@@ -27,6 +31,7 @@ export interface InstanceRoutesOptions {
readonly login?: InstanceLoginService; readonly login?: InstanceLoginService;
readonly deletion?: DeleteInstanceOperation; readonly deletion?: DeleteInstanceOperation;
readonly resources?: InstanceResourceService; readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean; readonly registerDeletionPreparationRoute?: boolean;
} }
const problem = ( const problem = (
@@ -303,6 +308,21 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
'The requested session operation could not be completed.', 'The requested session operation could not be completed.',
), ),
); );
if (error instanceof MessageServiceError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
return reply
.code(status)
.type('application/problem+json')
.send(
problem(
request,
status,
error.code,
'The requested message operation could not be completed.',
),
);
}
throw error; throw error;
} }
}; };
@@ -345,6 +365,55 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
options.resources!.get((request.params as { instanceId: string }).instanceId), options.resources!.get((request.params as { instanceId: string }).instanceId),
), ),
); );
if (options.messages) {
app.get(
'/api/v1/instances/:instanceId/messages',
wrap(async (request) => {
const query = request.query as Record<string, unknown>;
if (Object.keys(query).some((key) => !['limit', 'offset', 'direction'].includes(key)))
throw new MessageServiceError('VALIDATION_FAILED');
const integer = (key: 'limit' | 'offset', fallback: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
throw new MessageServiceError('VALIDATION_FAILED');
return Number(raw);
};
const direction = query.direction;
if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing')
throw new MessageServiceError('VALIDATION_FAILED');
return options.messages!.list((request.params as { instanceId: string }).instanceId, {
limit: integer('limit', 50),
offset: integer('offset', 0),
...(direction ? { direction } : {}),
});
}),
);
app.post(
'/api/v1/instances/:instanceId/messages/send',
{
bodyLimit: 8_192,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['phoneNumber', 'content'],
properties: {
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 1600 },
},
},
},
},
wrap(async (request) => {
const value = record(request.body);
return options.messages!.send((request.params as { instanceId: string }).instanceId, {
phoneNumber: typeof value.phoneNumber === 'string' ? value.phoneNumber : '',
content: typeof value.content === 'string' ? value.content : '',
});
}),
);
}
app.patch( app.patch(
'/api/v1/instances/:instanceId', '/api/v1/instances/:instanceId',
{ schema: { body: instancePatchSchema } }, { schema: { body: instancePatchSchema } },
+34 -2
View File
@@ -129,8 +129,12 @@ describe('React AppShell and Fleet vertical slice', () => {
'/instances/new', '/instances/new',
); );
const card = screen.getByRole('article', { name: 'Bravo 实例概览' }); const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
expect(within(card).getByText('40 ms')).toBeTruthy(); expect(within(card).queryByText('延迟')).toBeNull();
expect(within(card).getByText('2.0')).toBeTruthy(); expect(within(card).queryByText('版本')).toBeNull();
expect(within(card).queryByText('新鲜度')).toBeNull();
expect(within(card).queryByText('40 ms')).toBeNull();
expect(within(card).queryByText('2.0')).toBeNull();
expect(within(card).queryByText('可能过期')).toBeNull();
expect(within(card).getByText('18.4%')).toBeTruthy(); expect(within(card).getByText('18.4%')).toBeTruthy();
expect(within(card).getByText('63.2%')).toBeTruthy(); expect(within(card).getByText('63.2%')).toBeTruthy();
expect(within(card).getByText('46.7 °C')).toBeTruthy(); expect(within(card).getByText('46.7 °C')).toBeTruthy();
@@ -150,6 +154,34 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交'); expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
}); });
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
const { rerender } = render(
<AppShell
pathname="/jobs"
jobsDataSource={jobsDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
const navigation = screen.getByRole('navigation', { name: '全局导航' });
expect(within(navigation).queryByRole('link', { name: '任务' })).toBeNull();
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
expect(within(navigation).getByRole('link', { name: '实例总览' })).toBeTruthy();
expect(within(navigation).getByRole('link', { name: '设置' })).toBeTruthy();
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
rerender(
<AppShell
pathname="/audit"
auditDataSource={auditDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
});
it('loads route-owned instance context so overview-card navigation opens detail management', async () => { it('loads route-owned instance context so overview-card navigation opens detail management', async () => {
const instanceDataSource: InstanceDataSource = { const instanceDataSource: InstanceDataSource = {
get: vi.fn().mockResolvedValue({ get: vi.fn().mockResolvedValue({
+3 -3
View File
@@ -22,6 +22,7 @@ import { createInstanceApiDataSource } from './instances/instance-api-data-sourc
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js'; import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js'; import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
import { createMessagesApiDataSource } from './instances/messages-api-data-source.js';
import { import {
NotificationsModule, NotificationsModule,
type NotificationsDataSource, type NotificationsDataSource,
@@ -410,6 +411,7 @@ export function AppShell({
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []); const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
const resolvedJobsDataSource = useMemo( const resolvedJobsDataSource = useMemo(
() => jobsDataSource ?? createJobsApiDataSource(), () => jobsDataSource ?? createJobsApiDataSource(),
[jobsDataSource], [jobsDataSource],
@@ -488,8 +490,6 @@ export function AppShell({
{( {(
[ [
['fleet', '/fleet', '实例总览'], ['fleet', '/fleet', '实例总览'],
['jobs', '/jobs', '任务'],
['audit', '/audit', '审计'],
['settings', '/settings/instances', '设置'], ['settings', '/settings/instances', '设置'],
] as const ] as const
).map(([key, href, label]) => ( ).map(([key, href, label]) => (
@@ -520,7 +520,7 @@ export function AppShell({
overviewDataSource={overviewDataSource} overviewDataSource={overviewDataSource}
cellularDataSource={cellularDataSource} cellularDataSource={cellularDataSource}
deviceNetworkDataSource={deviceNetworkDataSource} deviceNetworkDataSource={deviceNetworkDataSource}
messagesDataSource={messagesDataSource} messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
callsDataSource={callsDataSource} callsDataSource={callsDataSource}
esimDataSource={esimDataSource} esimDataSource={esimDataSource}
notificationsDataSource={notificationsDataSource} notificationsDataSource={notificationsDataSource}
-12
View File
@@ -444,18 +444,6 @@ export function FleetPage({
<dt></dt> <dt></dt>
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd> <dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
</div>
<div>
<dt></dt>
<dd>{row.version ?? '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
</div>
</dl> </dl>
<div className="capability-tags" aria-label="能力"> <div className="capability-tags" aria-label="能力">
{row.capabilities.length > 0 ? ( {row.capabilities.length > 0 ? (
@@ -28,7 +28,7 @@ const capabilities: InstanceCapabilityMap = {
}; };
describe('InstanceDetail', () => { describe('InstanceDetail', () => {
it('makes only supported modules actionable and explains every other capability state', () => { it('keeps core overview and message modules actionable while explaining other capability states', () => {
render( render(
<InstanceDetail <InstanceDetail
instanceId="owner" instanceId="owner"
@@ -41,8 +41,10 @@ describe('InstanceDetail', () => {
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe( expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
'/instances/owner/overview', '/instances/owner/overview',
); );
expect(screen.queryByRole('link', { name: '消息' })).toBeNull(); expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe(
expect(screen.getByText('消息历史记录为只读。')).toBeTruthy(); '/instances/owner/messages',
);
expect(screen.queryByText('消息历史记录为只读。')).toBeNull();
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy(); expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy(); expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0); expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
+8 -11
View File
@@ -55,6 +55,11 @@ function canRender(capability: InstanceCapability): boolean {
return capability.state === 'supported' || capability.state === 'degraded'; return capability.state === 'supported' || capability.state === 'degraded';
} }
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
function canOpen(module: InstanceModule, capability: InstanceCapability): boolean {
return CORE_MODULES.has(module) || canRender(capability);
}
function explanation(capability: InstanceCapability): string | null { function explanation(capability: InstanceCapability): string | null {
if (capability.state === 'supported') return null; if (capability.state === 'supported') return null;
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state]; return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
@@ -131,14 +136,6 @@ export function InstanceDetail({
<dt></dt> <dt></dt>
<dd>{displayStatus(instance.status)}</dd> <dd>{displayStatus(instance.status)}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{displayStatus(instance.authentication)}</dd>
</div>
<div>
<dt></dt>
<dd>{displayStatus(instance.freshness)}</dd>
</div>
</dl> </dl>
{origin ? ( {origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer"> <a href={origin} target="_blank" rel="noopener noreferrer">
@@ -153,7 +150,7 @@ export function InstanceDetail({
const reason = explanation(capability); const reason = explanation(capability);
return ( return (
<li key={item} data-capability-state={capability.state}> <li key={item} data-capability-state={capability.state}>
{capability.state === 'supported' ? ( {canOpen(item, capability) ? (
<a <a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`} href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined} aria-current={module === item ? 'page' : undefined}
@@ -176,7 +173,7 @@ export function InstanceDetail({
<h1>{INSTANCE_MODULE_LABELS[module]}</h1> <h1>{INSTANCE_MODULE_LABELS[module]}</h1>
{loading ? <p role="status"></p> : null} {loading ? <p role="status"></p> : null}
{loadError ? <p role="alert">{loadError}</p> : null} {loadError ? <p role="alert">{loadError}</p> : null}
{!loading && canRender(activeCapability) ? ( {!loading && canOpen(module, activeCapability) ? (
<> <>
{activeCapability.state === 'degraded' ? ( {activeCapability.state === 'degraded' ? (
<p data-capability-state="degraded">{explanation(activeCapability)}</p> <p data-capability-state="degraded">{explanation(activeCapability)}</p>
@@ -184,7 +181,7 @@ export function InstanceDetail({
{moduleContent ?? <p>{INSTANCE_MODULE_LABELS[module]}</p>} {moduleContent ?? <p>{INSTANCE_MODULE_LABELS[module]}</p>}
</> </>
) : null} ) : null}
{!loading && !canRender(activeCapability) ? ( {!loading && !canOpen(module, activeCapability) ? (
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p> <p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
) : null} ) : null}
</div> </div>
@@ -0,0 +1,59 @@
import type { MessagesDataSource, MessagesSnapshot, SendMessageInput } from './messages-module.js';
interface Options {
readonly fetcher?: typeof fetch;
}
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export function createMessagesApiDataSource(options: Options = {}): MessagesDataSource {
const fetcher = options.fetcher ?? fetch;
return {
async load(instanceId, signal): Promise<MessagesSnapshot> {
const response = await fetcher(
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`,
{ headers: { accept: 'application/json' }, signal },
);
if (!response.ok) throw new Error('message load failed');
const root = record(await response.json());
if (!Array.isArray(root?.messages)) throw new Error('message load failed');
return {
messages: root.messages.flatMap((item) => {
const value = record(item);
return typeof value?.id === 'string' &&
typeof value.phoneNumber === 'string' &&
typeof value.content === 'string' &&
typeof value.timestamp === 'string' &&
typeof value.status === 'string' &&
typeof value.direction === 'string'
? [
{
id: value.id,
phoneNumber: value.phoneNumber,
content: value.content,
timestamp: value.timestamp,
status: value.status,
direction: value.direction,
},
]
: [];
}),
};
},
async send(instanceId: string, input: SendMessageInput): Promise<void> {
const response = await fetcher(
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages/send`,
{
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
},
);
if (!response.ok) throw new Error('message send failed');
},
};
}
+42 -199
View File
@@ -1,215 +1,58 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react'; import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { import { MessagesModule, type MessagesDataSource } from './messages-module.js';
MessagesModule,
type MessagesDataSource,
type MessagesSnapshot,
} from './messages-module.js';
afterEach(cleanup); const instance: InstanceContext = {
const owner: InstanceContext = {
id: 'alpha', id: 'alpha',
name: 'Alpha', name: 'Alpha',
origin: 'https://alpha.example', origin: 'http://192.168.1.2',
status: 'online', status: 'unknown',
authentication: 'authenticated', authentication: 'unknown',
freshness: 'fresh', freshness: 'unknown',
}; };
afterEach(cleanup);
const snapshot: MessagesSnapshot = { describe('MessagesModule', () => {
observedAt: '2026-07-17T12:00:00Z', it('shows the real message list including content and never exposes PDU', async () => {
sms: { const dataSource: MessagesDataSource = {
total: 42, load: vi.fn().mockResolvedValue({
inbound: 25, messages: [
outbound: 17, {
unread: 3, id: '32',
failed: 2, direction: 'incoming',
queued: 1, phoneNumber: '10086',
lastActivityAt: '2026-07-17T11:55:00Z', content: '余额提醒',
// Deliberate excess fields: payloads and recipient data must never reach the DOM. timestamp: '2026-07-18 09:09:20',
body: 'private message body', status: 'received',
content: 'private message content', pdu: 'secret-pdu',
recipient: '+15550199', },
recipientCredential: 'secret-recipient-token', ],
},
devices: [
{
deviceId: 'modem-1',
label: 'Primary modem',
state: 'online',
total: 30,
inbound: 18,
outbound: 12,
unread: 2,
failed: 1,
queued: 0,
lastActivityAt: '2026-07-17T11:54:00Z',
body: 'device body must not render',
phoneNumber: '+15550123',
password: 'device credential',
},
],
};
function deferredSource() {
let resolve!: (value: MessagesSnapshot) => void;
let reject!: (reason: unknown) => void;
const load = vi.fn<MessagesDataSource['load']>(
(_instanceId, _signal) =>
new Promise((done, fail) => {
void _instanceId;
void _signal;
resolve = done;
reject = fail;
}), }),
); send: vi.fn(),
return { };
source: { load }, render(<MessagesModule instance={instance} dataSource={dataSource} />);
load, expect(await screen.findByText('余额提醒')).toBeTruthy();
resolve: (value: MessagesSnapshot) => resolve(value), expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
reject: (reason: unknown) => reject(reason), expect(document.body.textContent).not.toContain('secret-pdu');
};
}
describe('Messages isolated read module', () => {
it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => {
const pending = deferredSource();
render(<MessagesModule instance={owner} dataSource={pending.source} />);
expect(screen.getByRole('status', { name: '消息加载状态' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot);
const sms = await screen.findByRole('region', { name: '短信汇总' });
expect(within(sms).getByText('42')).toBeTruthy();
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
const devices = screen.getByRole('region', { name: '设备消息汇总' });
expect(within(devices).getByText('modem-1')).toBeTruthy();
expect(within(devices).getByText('Primary modem')).toBeTruthy();
expect(within(devices).getByText('在线')).toBeTruthy();
expect(within(devices).queryByText('online')).toBeNull();
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
}); });
it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => { it('provides an intentional send form and submits exactly once', async () => {
render(<MessagesModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect(await screen.findByText('Primary modem')).toBeTruthy();
const text = document.body.textContent ?? '';
for (const secret of [
'private message body',
'private message content',
'+15550199',
'secret-recipient-token',
'device body must not render',
'+15550123',
'device credential',
]) {
expect(text).not.toContain(secret);
}
expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy();
expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
});
it('does not invent an endpoint when no source is injected', () => {
render(<MessagesModule instance={owner} />);
expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
/没有可用的安全消息只读数据源.*未签订契约的生产端点/i,
);
});
it('requires the exact owner to remain authenticated and clears prior owner data', async () => {
const load = vi.fn<MessagesDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<MessagesModule instance={owner} dataSource={{ load }} />);
expect(await screen.findByText('Primary modem')).toBeTruthy();
rerender(
<MessagesModule
instance={{ ...owner, id: 'bravo', name: 'Bravo', authentication: 'auth-required' }}
dataSource={{ load }}
/>,
);
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(screen.queryByText('Primary modem')).toBeNull();
expect(load).toHaveBeenCalledTimes(1);
});
it('uses a fixed safe error and retries without exposing rejection details', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const load = vi const send = vi.fn().mockResolvedValue(undefined);
.fn<MessagesDataSource['load']>() const dataSource: MessagesDataSource = {
.mockRejectedValueOnce(new Error('secret URL and recipient credential')) load: vi.fn().mockResolvedValue({ messages: [] }),
.mockResolvedValueOnce(snapshot); send,
render(<MessagesModule instance={owner} dataSource={{ load }} />); };
render(<MessagesModule instance={instance} dataSource={dataSource} />);
const alert = await screen.findByRole('alert'); await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
expect(alert.textContent).toContain('无法加载消息数据。'); await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
expect(alert.textContent).not.toContain('secret URL'); await user.click(screen.getByRole('button', { name: '发送短信' }));
await user.click(screen.getByRole('button', { name: '重试加载消息' })); expect(send).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Primary modem')).toBeTruthy(); expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
expect(load).toHaveBeenCalledTimes(2); expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
});
it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => {
const user = userEvent.setup();
const load = vi
.fn<MessagesDataSource['load']>()
.mockResolvedValueOnce(snapshot)
.mockRejectedValueOnce(new Error('unsafe detail'))
.mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] });
const source = { load };
const { rerender } = render(
<MessagesModule instance={owner} dataSource={source} refreshSignal={0} />,
);
expect(await screen.findByText('Primary modem')).toBeTruthy();
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
expect(screen.getByText('Primary modem')).toBeTruthy();
expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i);
expect(screen.getByText('Primary modem')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
expect(await screen.findByText('Replacement modem')).toBeTruthy();
});
it('aborts replaced reads and fences late responses from another owner', async () => {
const alpha = deferredSource();
const bravo = deferredSource();
const load = vi.fn<MessagesDataSource['load']>((instanceId, signal) =>
instanceId === 'alpha'
? alpha.source.load(instanceId, signal)
: bravo.source.load(instanceId, signal),
);
const source = { load };
const { rerender } = render(<MessagesModule instance={owner} dataSource={source} />);
const alphaSignal = load.mock.calls[0]?.[1];
rerender(
<MessagesModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
);
expect(alphaSignal?.aborted).toBe(true);
expect(screen.queryByText('Primary modem')).toBeNull();
alpha.resolve(snapshot);
bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] });
expect(await screen.findByText('Bravo modem')).toBeTruthy();
expect(screen.queryByText('Primary modem')).toBeNull();
});
it('marks owner-declared stale data while preserving aggregates', async () => {
render(
<MessagesModule
instance={{ ...owner, freshness: 'stale' }}
dataSource={{ load: async () => snapshot }}
/>,
);
expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch(
/可能已过期/i,
);
expect(screen.getByText('Primary modem')).toBeTruthy();
}); });
}); });
+122 -214
View File
@@ -1,251 +1,159 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Bounded primitives permitted at the Messages presentation boundary. */ export interface SmsMessage {
export type MessageMetadataValue = string | number | boolean | null; readonly id: string;
readonly direction: string;
/** Aggregate SMS metadata only. Message bodies, content, recipients, and credentials are absent. */ readonly phoneNumber: string;
export interface SmsAggregate { readonly content: string;
readonly total?: number | null; readonly timestamp: string;
readonly inbound?: number | null; readonly status: string;
readonly outbound?: number | null;
readonly unread?: number | null;
readonly failed?: number | null;
readonly queued?: number | null;
readonly lastActivityAt?: string | null;
} }
/** Non-sensitive device identity/status and aggregate counts only. */
export interface DeviceMessageAggregate extends SmsAggregate {
readonly deviceId?: string | null;
readonly label?: string | null;
readonly state?: string | null;
}
export interface MessagesSnapshot { export interface MessagesSnapshot {
readonly observedAt?: string; readonly messages: readonly SmsMessage[];
readonly sms: SmsAggregate; }
readonly devices: readonly DeviceMessageAggregate[]; export interface SendMessageInput {
readonly phoneNumber: string;
readonly content: string;
} }
export interface MessagesDataSource { export interface MessagesDataSource {
/** Supplied by the authenticated owner; this isolated module defines no network endpoint. */
load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>; load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>;
send(instanceId: string, input: SendMessageInput): Promise<void>;
} }
export interface MessagesModuleProps { export interface MessagesModuleProps {
readonly instance: InstanceContext; readonly instance: InstanceContext;
readonly dataSource?: MessagesDataSource; readonly dataSource?: MessagesDataSource;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown; readonly refreshSignal?: unknown;
} }
type ReadState = const directionLabel = (value: string): string =>
| { kind: 'idle'; ownerId: string } value === 'incoming' || value === 'received'
| { kind: 'loading'; ownerId: string; snapshot?: MessagesSnapshot } ? '收到'
| { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot } : value === 'outgoing' || value === 'sent'
| { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot }; ? '发送'
: '未知';
const SAFE_LOAD_ERROR = '无法加载消息数据。';
const AGGREGATE_FIELDS = [
['总计', 'total'],
['接收', 'inbound'],
['发送', 'outbound'],
['未读', 'unread'],
['失败', 'failed'],
['排队中', 'queued'],
['最近活动', 'lastActivityAt'],
] as const;
function displayRaw(value: MessageMetadataValue | undefined): string {
return value == null ? '不可用' : String(value);
}
function Fields({
values,
}: {
values: readonly (readonly [
label: string,
value: MessageMetadataValue | undefined,
localized?: boolean,
])[];
}) {
return (
<dl>
{values.map(([label, value, localized]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
</div>
))}
</dl>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<section className="messages-card" aria-label={label}>
<h2>{label}</h2>
{children}
</section>
);
}
function aggregateFields(
aggregate: SmsAggregate,
): readonly (readonly [string, MessageMetadataValue | undefined])[] {
// This constant-key projection is the presentation allowlist. Never enumerate source objects.
return AGGREGATE_FIELDS.map(([label, key]) => [label, aggregate[key]] as const);
}
function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) {
return (
<>
{snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<p></p>
<div className="messages-grid">
<Section label="短信汇总">
<Fields values={aggregateFields(snapshot.sms)} />
</Section>
<Section label="设备消息汇总">
{snapshot.devices.length ? (
snapshot.devices.map((device, index) => (
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
<Fields
values={[
['设备 ID', device.deviceId],
['标签', device.label],
['状态', device.state, true],
...aggregateFields(device),
]}
/>
</article>
))
) : (
<p></p>
)}
</Section>
</div>
<section className="state-panel" aria-label="消息操作">
<h2></h2>
<p></p>
</section>
</>
);
}
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) { export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
const requestOwner = useRef(0); const owner = useRef(0);
const [messages, setMessages] = useState<readonly SmsMessage[]>();
const [loadError, setLoadError] = useState(false);
const [retry, setRetry] = useState(0); const [retry, setRetry] = useState(0);
const [state, setState] = useState<ReadState>({ kind: 'idle', ownerId: instance.id }); const [phoneNumber, setPhoneNumber] = useState('');
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [sendState, setSendState] = useState<'success' | 'error'>();
useEffect(() => { useEffect(() => {
const request = ++requestOwner.current; const request = ++owner.current;
const ownerId = instance.id;
const controller = new AbortController(); const controller = new AbortController();
setMessages(undefined);
if (instance.authentication !== 'authenticated' || !dataSource) { setLoadError(false);
setState({ kind: 'idle', ownerId }); if (!dataSource) return () => controller.abort();
return () => controller.abort(); void dataSource.load(instance.id, controller.signal).then(
}
setState((current) => ({
kind: 'loading',
ownerId,
...(current.ownerId === ownerId &&
(current.kind === 'ready' || current.kind === 'error') &&
current.snapshot
? { snapshot: current.snapshot }
: {}),
}));
void dataSource.load(ownerId, controller.signal).then(
(snapshot) => { (snapshot) => {
if (request === requestOwner.current && !controller.signal.aborted) { if (request === owner.current && !controller.signal.aborted) setMessages(snapshot.messages);
setState({ kind: 'ready', ownerId, snapshot });
}
}, },
(_reason: unknown) => { () => {
void _reason; if (request === owner.current && !controller.signal.aborted) setLoadError(true);
if (request === requestOwner.current && !controller.signal.aborted) {
setState((current) => ({
kind: 'error',
ownerId,
...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot
? { snapshot: current.snapshot }
: {}),
}));
}
}, },
); );
return () => controller.abort(); return () => controller.abort();
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); }, [dataSource, instance.id, refreshSignal, retry]);
if (instance.authentication !== 'authenticated') { async function send(): Promise<void> {
if (!dataSource || sending) return;
setSending(true);
setSendState(undefined);
try {
await dataSource.send(instance.id, { phoneNumber, content });
setContent('');
setSendState('success');
setRetry((value) => value + 1);
} catch {
setSendState('error');
} finally {
setSending(false);
}
}
if (!dataSource)
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel" role="status">
</div> </div>
); );
}
if (!dataSource) {
return (
<div className="state-panel" role="status" aria-label="消息不可用">
</div>
);
}
const retainedSnapshot =
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
? state.snapshot
: undefined;
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) {
return (
<p role="status" aria-label="消息加载状态">
</p>
);
}
if (state.kind === 'error' && !retainedSnapshot) {
return (
<div className="state-panel state-error" role="alert">
<p>{SAFE_LOAD_ERROR}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
);
}
const currentSnapshot =
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot;
if (!currentSnapshot) return null;
return ( return (
<div className="messages-module"> <div className="messages-module">
{state.kind === 'loading' ? <p role="status"></p> : null} <section className="messages-card" aria-labelledby="send-sms-title">
{state.kind === 'error' ? ( <h2 id="send-sms-title"></h2>
<div className="state-panel state-error" role="alert"> <p></p>
<p></p> <form
<button type="button" onClick={() => setRetry((value) => value + 1)}> onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label>
<input
name="phoneNumber"
value={phoneNumber}
onChange={(event) => setPhoneNumber(event.target.value)}
minLength={3}
maxLength={32}
required
/>
</label>
<label>
<textarea
name="content"
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={1600}
required
/>
</label>
<button type="submit" disabled={sending || !phoneNumber.trim() || !content.trim()}>
{sending ? '正在发送…' : '发送短信'}
</button> </button>
</div> </form>
) : null} {sendState === 'success' ? <p role="status"></p> : null}
{instance.freshness !== 'fresh' ? ( {sendState === 'error' ? <p role="alert"></p> : null}
<p className="state-panel" role="status" aria-label="消息数据新鲜度"> </section>
使
</p> <section className="messages-card" aria-labelledby="sms-list-title">
) : null} <h2 id="sms-list-title"></h2>
<SnapshotView snapshot={currentSnapshot} /> {loadError ? (
<div role="alert">
<p></p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
) : messages === undefined ? (
<p role="status"></p>
) : messages.length === 0 ? (
<p></p>
) : (
<ol className="sms-list">
{messages.map((message) => (
<li key={message.id} className="sms-message-card">
<header>
<strong>
{directionLabel(message.direction)} · {message.phoneNumber}
</strong>
<time>{message.timestamp}</time>
</header>
<p>{message.content}</p>
<small>{message.status}</small>
</li>
))}
</ol>
)}
</section>
</div> </div>
); );
} }
+32 -3
View File
@@ -586,6 +586,30 @@ dd {
.instance-module-detail { .instance-module-detail {
min-width: 0; min-width: 0;
} }
.messages-module,
.messages-card,
.sms-list,
.sms-message-card {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.messages-card form,
.messages-card label {
display: grid;
min-width: 0;
gap: 0.5rem;
}
.messages-card input,
.messages-card textarea {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
}
.sms-list {
padding-inline-start: 1.25rem;
}
.overview-grid, .overview-grid,
.cellular-grid, .cellular-grid,
.device-network-grid, .device-network-grid,
@@ -681,11 +705,16 @@ main ul[aria-label='已配置实例'] {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.instance-context ul { .instance-context ul {
display: flex; display: grid;
overflow-x: auto; grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
} }
.instance-context li { .instance-context li {
min-width: max-content; min-width: 0;
}
.instance-context nav a,
.instance-context nav span {
overflow-wrap: anywhere;
} }
.fleet-toolbar label, .fleet-toolbar label,
.jobs-toolbar label { .jobs-toolbar label {