feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
+1
View File
@@ -16,6 +16,7 @@
"@multi-simadmin/contracts": "workspace:*",
"@multi-simadmin/operation-registry": "workspace:*",
"better-sqlite3": "12.11.1",
"cron-parser": "^5.6.2",
"drizzle-orm": "0.45.2",
"fastify": "5.10.0",
"tsx": "4.22.4"
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { nextOccurrence, previewCron, reconcileOccurrence } from './schedule-time.js';
describe('schedule time', () => {
it('calculates future occurrences in Beijing time across the UTC day boundary', () => {
expect(previewCron('0 9 * * *', 2, new Date('2026-07-30T00:30:00.000Z'))).toEqual([
'2026-07-30T01:00:00.000Z',
'2026-07-31T01:00:00.000Z',
]);
expect(nextOccurrence('30 0 * * *', new Date('2026-07-30T15:59:00.000Z'))).toBe(
'2026-07-30T16:30:00.000Z',
);
});
it('rejects seconds fields and invalid cron grammar', () => {
expect(() => previewCron('* * * * * *', 1)).toThrow(/five-field/i);
expect(() => previewCron('99 99 * * *', 1)).toThrow(/cron/i);
});
it('reconciles a missed due time according to the per-task policy', () => {
const common = {
cronExpression: '*/10 * * * *',
nextDueAt: '2026-07-30T01:00:00.000Z',
};
expect(
reconcileOccurrence(
{ ...common, misfirePolicy: 'skip' },
new Date('2026-07-30T01:25:00.000Z'),
),
).toEqual({
action: 'skip',
dueAt: '2026-07-30T01:20:00.000Z',
nextDueAt: '2026-07-30T01:30:00.000Z',
});
expect(
reconcileOccurrence(
{ ...common, misfirePolicy: 'catch-up-once' },
new Date('2026-07-30T01:25:00.000Z'),
),
).toEqual({
action: 'run',
dueAt: '2026-07-30T01:20:00.000Z',
nextDueAt: '2026-07-30T01:30:00.000Z',
});
});
});
@@ -0,0 +1,65 @@
import type { ScheduleMisfirePolicy } from '@multi-simadmin/contracts';
import { CronExpressionParser } from 'cron-parser';
const TIMEZONE = 'Asia/Shanghai';
function validateExpression(expression: string): string {
const clean = expression.trim().replace(/\s+/g, ' ');
if (clean.split(' ').length !== 5)
throw new TypeError('Cron must use standard five-field syntax');
return clean;
}
function parser(expression: string, currentDate: Date) {
try {
return CronExpressionParser.parse(validateExpression(expression), {
currentDate,
tz: TIMEZONE,
});
} catch (error) {
if (error instanceof TypeError && /five-field/.test(error.message)) throw error;
throw new TypeError('Cron expression is invalid');
}
}
export function previewCron(
expression: string,
count: number,
from = new Date(),
): readonly string[] {
if (!Number.isSafeInteger(count) || count < 1 || count > 10)
throw new TypeError('Cron preview count must be between 1 and 10');
const interval = parser(expression, from);
return Array.from({ length: count }, () => interval.next().toDate().toISOString());
}
export function nextOccurrence(expression: string, after: Date): string {
return parser(expression, after).next().toDate().toISOString();
}
export type ReconciledOccurrence =
| { readonly action: 'wait'; readonly dueAt: string; readonly nextDueAt: string }
| { readonly action: 'run' | 'skip'; readonly dueAt: string; readonly nextDueAt: string };
export function reconcileOccurrence(
task: {
readonly cronExpression: string;
readonly nextDueAt: string;
readonly misfirePolicy: ScheduleMisfirePolicy;
},
now: Date,
): ReconciledOccurrence {
const nextDue = new Date(task.nextDueAt);
if (!Number.isFinite(nextDue.getTime())) throw new TypeError('nextDueAt is invalid');
if (nextDue.getTime() > now.getTime())
return { action: 'wait', dueAt: task.nextDueAt, nextDueAt: task.nextDueAt };
const interval = parser(task.cronExpression, now);
const dueAt = interval.prev().toDate().toISOString();
const nextDueAt = nextOccurrence(task.cronExpression, now);
return {
action: task.misfirePolicy === 'catch-up-once' ? 'run' : 'skip',
dueAt,
nextDueAt,
};
}
@@ -0,0 +1,125 @@
import Database from 'better-sqlite3';
import { describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { ScheduledOperationDispatcher } from './scheduled-operation-dispatcher.js';
describe('ScheduledOperationDispatcher', () => {
it('executes restart targets independently and aggregates partial success', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
const execute = vi
.fn()
.mockResolvedValueOnce({ id: 'job-a', status: 'succeeded' })
.mockResolvedValueOnce({ id: 'job-b', status: 'failed' });
const dispatcher = new ScheduledOperationDispatcher({
db,
operations: {
prepare: vi.fn(async (input) => ({
id: `prep-${input.targets[0]?.instanceId}`,
confirmationToken: 'token',
})),
execute,
},
messages: { send: vi.fn() },
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
repository: new ScheduledTaskRepository(db),
});
const result = await dispatcher.dispatch(
{
id: 'task-1',
name: 'Restart',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] },
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
version: 1,
createdBy: 'operator',
updatedBy: 'operator',
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
},
[
{ id: 'a', revision: 1 },
{ id: 'b', revision: 2 },
],
{ actor: 'operator', requestId: 'request-1' },
);
expect(result).toEqual({ outcome: 'partially-succeeded', jobIds: ['job-a', 'job-b'] });
expect(execute).toHaveBeenCalledTimes(2);
db.close();
});
it('retries only failed SMS recipients using the task retry interval', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(
'a',
'Alpha',
'http://10.0.0.1',
1,
1,
'2026-07-30T00:00:00.000Z',
'2026-07-30T00:00:00.000Z',
);
const repository = new ScheduledTaskRepository(db);
const task = repository.create({
id: 'task-sms',
task: {
name: 'Notify lab',
operationType: 'send-sms',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['a'] },
sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance' },
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 1, intervalSeconds: 30 },
enabled: true,
},
smsSecretReference: 'memory://sms',
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
const attempts = new Map<string, number>();
const send = vi.fn(async (_instanceId: string, input: { phoneNumber: string }) => {
const count = (attempts.get(input.phoneNumber) ?? 0) + 1;
attempts.set(input.phoneNumber, count);
if (input.phoneNumber === '13800138000' && count === 1) throw new Error('temporary');
return { sent: true as const };
});
const sleep = vi.fn(async () => undefined);
const dispatcher = new ScheduledOperationDispatcher({
db,
operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send },
store: {
set: vi.fn(),
get: vi.fn(async () =>
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
),
delete: vi.fn(),
},
repository,
sleep,
});
const result = await dispatcher.dispatch(task, [{ id: 'a', revision: 1 }], {
actor: 'operator',
requestId: 'request-1',
});
expect(result.outcome).toBe('succeeded');
expect(send).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(1);
expect(sleep).toHaveBeenCalledWith(30_000);
db.close();
});
});
@@ -0,0 +1,240 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Job, PrepareOperationRequest, ScheduledTask } from '@multi-simadmin/contracts';
import type { InstanceMessageService } from '../messages/instance-message-service.js';
import type { SecureOperationExecution } from '../operations/secure-operation-execution.js';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import type { DispatchResult } from './scheduler-coordinator.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import type { ResolvedTarget } from './target-resolver.js';
interface OperationExecutor {
prepare(
input: PrepareOperationRequest,
requestId?: string,
): Promise<{ id: string; confirmationToken: string }>;
execute(
input: { preparationId: string; confirmationToken: string },
actor: string,
requestId: string,
): Promise<Pick<Job, 'id' | 'status'>>;
}
interface MessageExecutor {
send(
instanceId: string,
input: { phoneNumber: string; content: string },
): Promise<{ readonly sent: true }>;
}
export interface ScheduledOperationDispatcherOptions {
readonly db: SqliteDatabase;
readonly operations: Pick<SecureOperationExecution, 'prepare' | 'execute'> | OperationExecutor;
readonly messages: Pick<InstanceMessageService, 'send'> | MessageExecutor;
readonly store: SecretStore;
readonly repository: ScheduledTaskRepository;
readonly now?: () => Date;
readonly id?: () => string;
readonly sleep?: (milliseconds: number) => Promise<void>;
}
function aggregate(successes: number, failures: number): DispatchResult['outcome'] {
if (successes > 0 && failures > 0) return 'partially-succeeded';
return failures > 0 ? 'failed' : 'succeeded';
}
export class ScheduledOperationDispatcher {
private readonly now: () => Date;
private readonly id: () => string;
private readonly sleep: (milliseconds: number) => Promise<void>;
constructor(private readonly options: ScheduledOperationDispatcherOptions) {
this.now = options.now ?? (() => new Date());
this.id = options.id ?? randomUUID;
this.sleep =
options.sleep ??
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
}
async dispatch(
task: ScheduledTask,
targets: readonly ResolvedTarget[],
context: { readonly actor: string; readonly requestId: string },
): Promise<DispatchResult> {
if (task.operationType === 'send-sms') return this.dispatchSms(task, targets, context);
const operationId =
task.operationType === 'restart-service' ? 'postServiceRestart' : 'postSystemReboot';
const schema =
task.operationType === 'restart-service'
? 'simadmin.58e2204.postServiceRestart.parameters.v1'
: 'simadmin.58e2204.postSystemReboot.parameters.v1';
const fields =
task.operationType === 'reboot-system'
? [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }]
: [];
const jobIds: string[] = [];
let successes = 0;
let failures = 0;
for (const target of targets) {
let succeeded = false;
for (let attempt = 0; attempt <= task.retryPolicy.maxRetries; attempt += 1) {
try {
const preparation = await this.options.operations.prepare(
{
operationId,
targets: [{ instanceId: target.id, revision: target.revision }],
parameters: { parameterSchemaId: schema, fields },
},
context.requestId,
);
const job = await this.options.operations.execute(
{ preparationId: preparation.id, confirmationToken: preparation.confirmationToken },
context.actor,
context.requestId,
);
jobIds.push(job.id);
if (job.status === 'succeeded') {
succeeded = true;
break;
}
} catch {
// Continue only within the task's explicit bounded retry policy.
}
if (!succeeded && attempt < task.retryPolicy.maxRetries)
await this.sleep(task.retryPolicy.intervalSeconds * 1_000);
}
if (succeeded) successes += 1;
else failures += 1;
}
return { outcome: aggregate(successes, failures), jobIds };
}
private async dispatchSms(
task: ScheduledTask,
targets: readonly ResolvedTarget[],
context: { readonly actor: string; readonly requestId: string },
): Promise<DispatchResult> {
const reference = this.options.repository.getSmsSecretReference(task.id);
if (!reference)
return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] };
const secret = await this.options.store.get(reference);
if (!secret) return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] };
let payload: { recipients: string[]; content: string };
try {
payload = JSON.parse(secret) as typeof payload;
if (
!Array.isArray(payload.recipients) ||
!payload.recipients.length ||
typeof payload.content !== 'string' ||
!payload.content
)
throw new Error('invalid');
} catch {
return { outcome: 'needs-attention', reason: 'SMS secret is invalid', jobIds: [] };
}
const jobIds: string[] = [];
let successes = 0;
let failures = 0;
for (const target of targets) {
const result = await this.recordSmsJob(target.id, payload, task.retryPolicy, context);
jobIds.push(result.id);
if (result.succeeded) successes += 1;
else failures += 1;
}
return { outcome: aggregate(successes, failures), jobIds };
}
private async recordSmsJob(
instanceId: string,
payload: { recipients: readonly string[]; content: string },
retryPolicy: ScheduledTask['retryPolicy'],
context: { readonly actor: string; readonly requestId: string },
): Promise<{ readonly id: string; readonly succeeded: boolean }> {
const ids = { job: this.id(), item: this.id(), attempt: this.id(), audit: this.id() };
const startedAt = this.now().toISOString();
const digest = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
this.options.db.transaction(() => {
this.options.db
.prepare(
`INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
VALUES (?,?,'sendSms','R2','running',?,?,?,?,?,?)`,
)
.run(
ids.job,
ids.job,
context.actor,
context.requestId,
digest,
startedAt,
startedAt,
startedAt,
);
this.options.db
.prepare(
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)",
)
.run(ids.attempt, ids.job, startedAt, startedAt);
this.options.db
.prepare(
"INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES (?,?,?,1,'running',?,?,?)",
)
.run(ids.item, ids.job, instanceId, startedAt, startedAt, startedAt);
})();
let succeeded = true;
for (const recipient of payload.recipients) {
let delivered = false;
for (let attempt = 0; attempt <= retryPolicy.maxRetries; attempt += 1) {
try {
await this.options.messages.send(instanceId, {
phoneNumber: recipient,
content: payload.content,
});
delivered = true;
break;
} catch {
delivered = false;
}
if (!delivered && attempt < retryPolicy.maxRetries)
await this.sleep(retryPolicy.intervalSeconds * 1_000);
}
if (!delivered) succeeded = false;
}
const finishedAt = this.now().toISOString();
const status = succeeded ? 'succeeded' : 'failed';
this.options.db.transaction(() => {
this.options.db
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(status, finishedAt, finishedAt, ids.job);
this.options.db
.prepare(
'UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE id=?',
)
.run(status, succeeded ? 'SMS_SENT' : 'SMS_FAILED', finishedAt, finishedAt, ids.item);
this.options.db
.prepare('UPDATE job_attempts SET status=?,finished_at=? WHERE id=?')
.run(status, finishedAt, ids.attempt);
this.options.db
.prepare(
`INSERT INTO audit_events
(id,instance_id,job_id,actor,operation_id,risk_level,request_id,parameters_summary_json,body_digest,result_code,duration_ms,created_at)
VALUES (?,?,?,?,'sendSms','R2',?,?,?,?,0,?)`,
)
.run(
ids.audit,
instanceId,
ids.job,
context.actor,
context.requestId,
JSON.stringify([
{ fieldId: 'recipients', displayValue: '[REDACTED]', redacted: true },
{ fieldId: 'content', displayValue: '[REDACTED]', redacted: true },
]),
digest,
succeeded ? 'SMS_SENT' : 'SMS_FAILED',
finishedAt,
);
})();
return { id: ids.job, succeeded };
}
}
@@ -0,0 +1,173 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
return { db, repository: new ScheduledTaskRepository(db) };
}
const task = {
name: 'Morning restart',
operationType: 'restart-service' as const,
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai' as const,
targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a', 'instance-b'] },
misfirePolicy: 'skip' as const,
overlapPolicy: 'skip' as const,
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
};
describe('ScheduledTaskRepository', () => {
it('round-trips a versioned task without exposing secret payload data', () => {
const { db, repository } = fixture();
const created = repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
nextDueAt: '2026-07-30T01:00:00.000Z',
});
expect(created).toMatchObject({
id: 'task-1',
version: 1,
nextDueAt: '2026-07-30T01:00:00.000Z',
});
expect(repository.get('task-1')).toEqual(created);
expect(JSON.stringify(created)).not.toMatch(/secretReference|content|recipient/i);
db.close();
});
it('claims the same scheduled occurrence once and preserves its immutable snapshot', () => {
const { db, repository } = fixture();
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
const dueAt = '2026-07-30T01:00:00.000Z';
const first = repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt,
claimedAt: '2026-07-30T01:00:01.000Z',
triggerSource: 'scheduled',
targetSnapshot: ['instance-a', 'instance-b'],
taskSnapshot: task,
});
const duplicate = repository.claimOccurrence({
id: 'run-2',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt,
claimedAt: '2026-07-30T01:00:02.000Z',
triggerSource: 'scheduled',
targetSnapshot: ['changed'],
taskSnapshot: task,
});
expect(first).toMatchObject({ id: 'run-1', targetSnapshot: ['instance-a', 'instance-b'] });
expect(duplicate).toBeNull();
expect(repository.getRun('run-1')?.targetSnapshot).toEqual(['instance-a', 'instance-b']);
db.close();
});
it('rolls back due-time advancement when scheduled claim preparation fails', () => {
const { db, repository } = fixture();
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
nextDueAt: '2026-07-30T01:00:00.000Z',
});
expect(() =>
repository.claimScheduledOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt: '2026-07-30T01:00:00.000Z',
nextDueAt: '2026-07-31T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:00.000Z',
taskSnapshot: task,
overlapPolicy: 'skip',
resolveTargets: () => {
throw new Error('target resolution failed');
},
}),
).toThrow('target resolution failed');
expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z');
expect(repository.listRuns()).toHaveLength(0);
db.close();
});
it('soft deletes task configuration while retaining completed run history', () => {
const { db, repository } = fixture();
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt: '2026-07-30T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:01.000Z',
triggerSource: 'scheduled',
targetSnapshot: [],
taskSnapshot: task,
});
repository.finishRun('run-1', {
outcome: 'no-targets',
reason: 'No enabled instances matched',
jobIds: [],
finishedAt: '2026-07-30T01:00:02.000Z',
});
repository.softDelete('task-1', 1, 'operator', '2026-07-30T02:00:00.000Z');
expect(repository.get('task-1')).toBeNull();
expect(repository.getRun('run-1')).toMatchObject({ outcome: 'no-targets' });
db.close();
});
it('reconciles interrupted started runs to needs-attention', () => {
const { db, repository } = fixture();
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt: '2026-07-30T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:00.000Z',
triggerSource: 'scheduled',
targetSnapshot: ['instance-a'],
taskSnapshot: task,
});
repository.startRun('run-1', '2026-07-30T01:00:01.000Z');
expect(repository.reconcileInterruptedRuns('2026-07-30T02:00:00.000Z')).toBe(1);
expect(repository.getRun('run-1')).toMatchObject({
outcome: 'needs-attention',
reason: 'Scheduler stopped before the run outcome was known',
finishedAt: '2026-07-30T02:00:00.000Z',
});
expect(repository.hasActiveRun('task-1')).toBe(false);
db.close();
});
});
@@ -0,0 +1,485 @@
import type {
CreateScheduledTaskRequest,
ScheduledRun,
ScheduledRunOutcome,
ScheduledRunTriggerSource,
ScheduledTask,
} from '@multi-simadmin/contracts';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
interface ScheduledTaskRow {
id: string;
name: string;
operation_type: ScheduledTask['operationType'];
enabled: number;
version: number;
cron_expression: string;
timezone: ScheduledTask['timezone'];
target_selector_json: string;
sms_secret_reference: string | null;
sms_recipient_count: number | null;
effective_start_at: string | null;
effective_end_at: string | null;
misfire_policy: ScheduledTask['misfirePolicy'];
overlap_policy: ScheduledTask['overlapPolicy'];
retry_policy_json: string;
next_due_at: string | null;
last_evaluated_at: string | null;
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
}
interface ScheduledRunRow {
id: string;
scheduled_task_id: string;
schedule_version: number;
task_snapshot_json: string;
due_at: string;
claimed_at: string;
started_at: string | null;
finished_at: string | null;
target_snapshot_json: string;
outcome: ScheduledRunOutcome | null;
reason: string | null;
job_ids_json: string;
trigger_source: ScheduledRunTriggerSource;
attempt: number;
}
export interface CreateTaskRecord {
readonly id: string;
readonly task: CreateScheduledTaskRequest;
readonly smsSecretReference?: string;
readonly createdBy: string;
readonly now: string;
readonly nextDueAt?: string;
}
export interface UpdateTaskRecord {
readonly id: string;
readonly version: number;
readonly task: CreateScheduledTaskRequest;
readonly smsSecretReference?: string;
readonly updatedBy: string;
readonly now: string;
readonly nextDueAt?: string;
}
export interface ClaimOccurrenceRecord {
readonly id: string;
readonly scheduledTaskId: string;
readonly scheduleVersion: number;
readonly dueAt: string;
readonly claimedAt: string;
readonly triggerSource: ScheduledRunTriggerSource;
readonly targetSnapshot: readonly string[];
readonly taskSnapshot: CreateScheduledTaskRequest;
readonly attempt?: number;
}
export interface ScheduledClaimTarget {
readonly id: string;
readonly revision: number;
}
export interface ClaimScheduledOccurrenceRecord {
readonly id: string;
readonly scheduledTaskId: string;
readonly scheduleVersion: number;
readonly dueAt: string;
readonly nextDueAt: string;
readonly claimedAt: string;
readonly taskSnapshot: CreateScheduledTaskRequest;
readonly overlapPolicy: ScheduledTask['overlapPolicy'];
readonly resolveTargets: () => {
readonly targets: readonly ScheduledClaimTarget[];
readonly targetSnapshot?: readonly string[];
readonly attentionReason?: string;
};
readonly terminal?: {
readonly outcome: ScheduledRunOutcome;
readonly reason: string;
};
}
export interface ScheduledClaimResult {
readonly run: ScheduledRun;
readonly targets: readonly ScheduledClaimTarget[];
readonly disposition: 'execute' | 'queued' | 'finished';
}
export interface FinishRunRecord {
readonly outcome: ScheduledRunOutcome;
readonly reason?: string;
readonly jobIds: readonly string[];
readonly finishedAt: string;
}
function parseJson<T>(value: string, label: string): T {
try {
return JSON.parse(value) as T;
} catch {
throw new Error(`Stored ${label} is invalid`);
}
}
function projectTask(row: ScheduledTaskRow): ScheduledTask {
return {
id: row.id,
name: row.name,
operationType: row.operation_type,
cronExpression: row.cron_expression,
timezone: row.timezone,
targetSelector: parseJson(row.target_selector_json, 'target selector'),
...(row.sms_secret_reference
? { sms: { configured: true, recipientCount: row.sms_recipient_count ?? 0 } }
: {}),
...(row.effective_start_at ? { effectiveStartAt: row.effective_start_at } : {}),
...(row.effective_end_at ? { effectiveEndAt: row.effective_end_at } : {}),
misfirePolicy: row.misfire_policy,
overlapPolicy: row.overlap_policy,
retryPolicy: parseJson(row.retry_policy_json, 'retry policy'),
enabled: row.enabled === 1,
version: row.version,
...(row.next_due_at ? { nextDueAt: row.next_due_at } : {}),
...(row.last_evaluated_at ? { lastEvaluatedAt: row.last_evaluated_at } : {}),
createdBy: row.created_by,
updatedBy: row.updated_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function projectRun(row: ScheduledRunRow): ScheduledRun {
const task = parseJson<CreateScheduledTaskRequest>(row.task_snapshot_json, 'task snapshot');
return {
id: row.id,
scheduledTaskId: row.scheduled_task_id,
scheduleVersion: row.schedule_version,
taskName: task.name,
operationType: task.operationType,
dueAt: row.due_at,
claimedAt: row.claimed_at,
...(row.started_at ? { startedAt: row.started_at } : {}),
...(row.finished_at ? { finishedAt: row.finished_at } : {}),
targetSnapshot: parseJson(row.target_snapshot_json, 'target snapshot'),
...(row.outcome ? { outcome: row.outcome } : {}),
...(row.reason ? { reason: row.reason } : {}),
jobIds: parseJson(row.job_ids_json, 'job ids'),
triggerSource: row.trigger_source,
attempt: row.attempt,
};
}
export class ScheduledTaskRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateTaskRecord): ScheduledTask {
const task = input.task;
this.db
.prepare(
`INSERT INTO scheduled_tasks
(id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy,
overlap_policy,retry_policy_json,next_due_at,created_by,updated_by,created_at,updated_at)
VALUES (?,?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
.run(
input.id,
task.name,
task.operationType,
task.enabled ? 1 : 0,
task.cronExpression,
task.timezone,
JSON.stringify(task.targetSelector),
input.smsSecretReference ?? null,
task.sms?.recipients.length ?? null,
task.effectiveStartAt ?? null,
task.effectiveEndAt ?? null,
task.misfirePolicy,
task.overlapPolicy,
JSON.stringify(task.retryPolicy),
input.nextDueAt ?? null,
input.createdBy,
input.createdBy,
input.now,
input.now,
);
const created = this.get(input.id);
if (!created) throw new Error('Scheduled task was not persisted');
return created;
}
update(input: UpdateTaskRecord): ScheduledTask {
const task = input.task;
const result = this.db
.prepare(
`UPDATE scheduled_tasks
SET name = ?, operation_type = ?, enabled = ?, version = version + 1,
cron_expression = ?, timezone = ?, target_selector_json = ?,
sms_secret_reference = ?, sms_recipient_count = ?, effective_start_at = ?,
effective_end_at = ?, misfire_policy = ?, overlap_policy = ?, retry_policy_json = ?,
next_due_at = ?, last_evaluated_at = NULL, updated_by = ?, updated_at = ?
WHERE id = ? AND version = ? AND deleted_at IS NULL`,
)
.run(
task.name,
task.operationType,
task.enabled ? 1 : 0,
task.cronExpression,
task.timezone,
JSON.stringify(task.targetSelector),
input.smsSecretReference ?? null,
task.sms?.recipients.length ?? null,
task.effectiveStartAt ?? null,
task.effectiveEndAt ?? null,
task.misfirePolicy,
task.overlapPolicy,
JSON.stringify(task.retryPolicy),
input.nextDueAt ?? null,
input.updatedBy,
input.now,
input.id,
input.version,
);
if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed');
const updated = this.get(input.id);
if (!updated) throw new Error('Scheduled task was not persisted');
return updated;
}
get(id: string): ScheduledTask | null {
const row = this.db
.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND deleted_at IS NULL')
.get(id) as ScheduledTaskRow | undefined;
return row ? projectTask(row) : null;
}
list(): readonly ScheduledTask[] {
return (
this.db
.prepare(
'SELECT * FROM scheduled_tasks WHERE deleted_at IS NULL ORDER BY updated_at DESC, id ASC',
)
.all() as ScheduledTaskRow[]
).map(projectTask);
}
getSmsSecretReference(id: string): string | undefined {
const row = this.db
.prepare(
'SELECT sms_secret_reference FROM scheduled_tasks WHERE id = ? AND deleted_at IS NULL',
)
.get(id) as { sms_secret_reference: string | null } | undefined;
return row?.sms_secret_reference ?? undefined;
}
setEnabled(
id: string,
version: number,
enabled: boolean,
actor: string,
now: string,
): ScheduledTask {
const result = this.db
.prepare(
`UPDATE scheduled_tasks
SET enabled = ?, version = version + 1, updated_by = ?, updated_at = ?
WHERE id = ? AND version = ? AND deleted_at IS NULL`,
)
.run(enabled ? 1 : 0, actor, now, id, version);
if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed');
const task = this.get(id);
if (!task) throw new Error('Scheduled task was not persisted');
return task;
}
advanceNextDue(id: string, version: number, nextDueAt: string, evaluatedAt: string): boolean {
return (
this.db
.prepare(
`UPDATE scheduled_tasks SET next_due_at = ?, last_evaluated_at = ?
WHERE id = ? AND version = ? AND enabled = 1 AND deleted_at IS NULL`,
)
.run(nextDueAt, evaluatedAt, id, version).changes === 1
);
}
startRun(id: string, startedAt: string): void {
const result = this.db
.prepare(
'UPDATE scheduled_runs SET started_at = ? WHERE id = ? AND started_at IS NULL AND finished_at IS NULL',
)
.run(startedAt, id);
if (result.changes !== 1) throw new Error('Scheduled run is missing or already started');
}
hasActiveRun(scheduledTaskId: string): boolean {
return !!this.db
.prepare(
'SELECT 1 FROM scheduled_runs WHERE scheduled_task_id = ? AND started_at IS NOT NULL AND finished_at IS NULL LIMIT 1',
)
.get(scheduledTaskId);
}
reconcileInterruptedRuns(finishedAt: string): number {
return this.db
.prepare(
`UPDATE scheduled_runs
SET outcome = 'needs-attention',
reason = 'Scheduler stopped before the run outcome was known',
finished_at = ?
WHERE started_at IS NOT NULL AND finished_at IS NULL`,
)
.run(finishedAt).changes;
}
getQueuedRun(scheduledTaskId: string): ScheduledRun | null {
const row = this.db
.prepare(
`SELECT * FROM scheduled_runs
WHERE scheduled_task_id = ? AND started_at IS NULL AND finished_at IS NULL
ORDER BY claimed_at ASC, id ASC LIMIT 1`,
)
.get(scheduledTaskId) as ScheduledRunRow | undefined;
return row ? projectRun(row) : null;
}
claimOccurrence(input: ClaimOccurrenceRecord): ScheduledRun | null {
const result = this.db
.prepare(
`INSERT OR IGNORE INTO scheduled_runs
(id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,target_snapshot_json,
job_ids_json,trigger_source,attempt)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
)
.run(
input.id,
input.scheduledTaskId,
input.scheduleVersion,
JSON.stringify(input.taskSnapshot),
input.dueAt,
input.claimedAt,
JSON.stringify(input.targetSnapshot),
'[]',
input.triggerSource,
input.attempt ?? 1,
);
return result.changes === 0 ? null : this.getRun(input.id);
}
claimScheduledOccurrence(input: ClaimScheduledOccurrenceRecord): ScheduledClaimResult | null {
return this.db.transaction((): ScheduledClaimResult | null => {
const advanced = this.db
.prepare(
`UPDATE scheduled_tasks SET next_due_at = ?, last_evaluated_at = ?
WHERE id = ? AND version = ? AND enabled = 1 AND deleted_at IS NULL AND next_due_at = ?`,
)
.run(
input.nextDueAt,
input.claimedAt,
input.scheduledTaskId,
input.scheduleVersion,
input.dueAt,
);
if (advanced.changes !== 1) return null;
const resolution = input.terminal
? { targets: [] as readonly ScheduledClaimTarget[] }
: input.resolveTargets();
const targets = [...resolution.targets];
let disposition: ScheduledClaimResult['disposition'] = 'execute';
let terminal =
input.terminal ??
(resolution.attentionReason
? { outcome: 'needs-attention' as const, reason: resolution.attentionReason }
: undefined);
if (!terminal && this.hasActiveRun(input.scheduledTaskId)) {
const queued = this.getQueuedRun(input.scheduledTaskId);
if (input.overlapPolicy === 'queue-once' && !queued) {
disposition = 'queued';
} else {
disposition = 'finished';
terminal = {
outcome: 'skipped',
reason:
input.overlapPolicy === 'queue-once'
? 'Overlap queue is full'
: 'Overlapping occurrence skipped by task policy',
};
}
}
const claimed = this.claimOccurrence({
id: input.id,
scheduledTaskId: input.scheduledTaskId,
scheduleVersion: input.scheduleVersion,
dueAt: input.dueAt,
claimedAt: input.claimedAt,
triggerSource: 'scheduled',
targetSnapshot: resolution.targetSnapshot ?? targets.map((target) => target.id),
taskSnapshot: input.taskSnapshot,
});
if (!claimed) return null;
if (terminal) {
return {
run: this.finishRun(claimed.id, {
outcome: terminal.outcome,
reason: terminal.reason,
jobIds: [],
finishedAt: input.claimedAt,
}),
targets,
disposition: 'finished',
};
}
return { run: claimed, targets, disposition };
})();
}
getRun(id: string): ScheduledRun | null {
const row = this.db.prepare('SELECT * FROM scheduled_runs WHERE id = ?').get(id) as
| ScheduledRunRow
| undefined;
return row ? projectRun(row) : null;
}
listRuns(scheduledTaskId?: string): readonly ScheduledRun[] {
const rows = scheduledTaskId
? (this.db
.prepare(
'SELECT * FROM scheduled_runs WHERE scheduled_task_id = ? ORDER BY due_at DESC, id ASC',
)
.all(scheduledTaskId) as ScheduledRunRow[])
: (this.db
.prepare('SELECT * FROM scheduled_runs ORDER BY due_at DESC, id ASC')
.all() as ScheduledRunRow[]);
return rows.map(projectRun);
}
finishRun(id: string, input: FinishRunRecord): ScheduledRun {
const result = this.db
.prepare(
`UPDATE scheduled_runs SET outcome = ?, reason = ?, job_ids_json = ?, finished_at = ?
WHERE id = ? AND finished_at IS NULL`,
)
.run(input.outcome, input.reason ?? null, JSON.stringify(input.jobIds), input.finishedAt, id);
if (result.changes !== 1) throw new Error('Scheduled run is missing or already finished');
const run = this.getRun(id);
if (!run) throw new Error('Scheduled run was not persisted');
return run;
}
softDelete(id: string, version: number, actor: string, now: string): void {
const result = this.db
.prepare(
`UPDATE scheduled_tasks SET enabled = 0, version = version + 1, updated_by = ?, updated_at = ?, deleted_at = ?
WHERE id = ? AND version = ? AND deleted_at IS NULL`,
)
.run(actor, now, now, id, version);
if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed');
}
}
@@ -0,0 +1,235 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { ScheduledTaskService } from './scheduled-task-service.js';
class MemorySecrets implements SecretStore {
readonly values = new Map<string, string>();
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
blockRotations = false;
private rotationWaiters: Array<() => void> = [];
async set(key: SecretKey, value: string): Promise<string> {
const reference = `memory://${key.instanceId}/${key.purpose}/${key.slot ?? 'default'}`;
this.setCalls.push({ key, value });
this.values.set(reference, value);
if (this.blockRotations && key.slot) {
await new Promise<void>((resolve) => {
this.rotationWaiters.push(resolve);
if (this.rotationWaiters.length === 2) {
for (const waiter of this.rotationWaiters.splice(0)) waiter();
}
});
}
return reference;
}
async get(reference: string): Promise<string | undefined> {
return this.values.get(reference);
}
async delete(reference: string): Promise<boolean> {
return this.values.delete(reference);
}
}
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const store = new MemorySecrets();
const repository = new ScheduledTaskRepository(db);
let id = 0;
const service = new ScheduledTaskService({
repository,
store,
now: () => new Date('2026-07-30T00:00:00.000Z'),
id: () => `task-${++id}`,
});
return { db, repository, service, store };
}
const base = {
name: 'Morning SMS',
operationType: 'send-sms',
cronExpression: '0 9 * * *',
targetSelector: { mode: 'tags', match: 'all', tags: ['lab'] },
sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' },
};
describe('ScheduledTaskService', () => {
it('stores SMS payload only in the secret store and returns a redacted summary', async () => {
const { db, service, store } = fixture();
const created = await service.create('operator', base);
expect(created.sms).toEqual({ configured: true, recipientCount: 2 });
expect(JSON.stringify(created)).not.toContain('13800138000');
expect(JSON.stringify(created)).not.toContain('Maintenance complete');
expect(store.setCalls[0]?.value).toBe(
JSON.stringify({
recipients: ['13800138000', '13900139000'],
content: 'Maintenance complete',
}),
);
const persisted = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get('task-1');
expect(JSON.stringify(persisted)).not.toContain('13800138000');
expect(JSON.stringify(persisted)).not.toContain('Maintenance complete');
db.close();
});
it('increments the optimistic version when pausing and rejects a stale update', async () => {
const { db, service } = fixture();
const created = await service.create('operator', {
...base,
operationType: 'restart-service',
sms: undefined,
});
expect(service.setEnabled('operator', created.id, created.version, false).version).toBe(2);
expect(() => service.setEnabled('operator', created.id, created.version, true)).toThrow(
/version/i,
);
db.close();
});
it('does not schedule the first occurrence before the effective start', async () => {
const { db, service } = fixture();
const created = await service.create('operator', {
...base,
operationType: 'restart-service',
sms: undefined,
effectiveStartAt: '2026-08-05T00:00:00.000Z',
});
expect(created.nextDueAt).toBe('2026-08-05T01:00:00.000Z');
db.close();
});
it('keeps immutable run history after deleting its schedule', async () => {
const { db, repository, service } = fixture();
const created = await service.create('operator', {
...base,
operationType: 'restart-service',
sms: undefined,
});
repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: created.id,
scheduleVersion: created.version,
dueAt: '2026-07-30T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:00.000Z',
triggerSource: 'manual',
targetSnapshot: [],
taskSnapshot: {
name: base.name,
cronExpression: base.cronExpression,
targetSelector: { mode: 'tags' as const, match: 'all' as const, tags: ['lab'] },
operationType: 'restart-service',
timezone: 'Asia/Shanghai',
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
},
});
await service.remove('operator', created.id, created.version);
expect(service.get(created.id)).toBeNull();
expect(repository.getRun('run-1')).not.toBeNull();
db.close();
});
it('updates the complete task, preserves an unchanged SMS secret, and advances the version', async () => {
const { db, service, store } = fixture();
const created = await service.create('operator', base);
const reference = store.setCalls[0]?.key;
const updated = await service.update('editor', created.id, created.version, {
name: 'Evening SMS',
cronExpression: '30 18 * * *',
effectiveStartAt: '2026-08-01T00:00:00.000Z',
effectiveEndAt: '2026-09-01T00:00:00.000Z',
overlapPolicy: 'queue-once',
});
expect(updated).toMatchObject({
name: 'Evening SMS',
cronExpression: '30 18 * * *',
overlapPolicy: 'queue-once',
version: 2,
updatedBy: 'editor',
sms: { configured: true, recipientCount: 2 },
});
expect(store.setCalls).toHaveLength(1);
expect(store.setCalls[0]?.key).toEqual(reference);
db.close();
});
it('rotates edited SMS secrets and validates retry policy against the effective operation', async () => {
const { db, service, store } = fixture();
const created = await service.create('operator', base);
const oldReference = [...store.values.keys()][0];
const updated = await service.update('editor', created.id, created.version, {
sms: { recipients: ['13700137000'], content: 'Updated content' },
});
expect(updated.sms).toEqual({ configured: true, recipientCount: 1 });
expect(store.setCalls).toHaveLength(2);
expect(oldReference ? store.values.has(oldReference) : true).toBe(false);
const reboot = await service.create('operator', {
...base,
name: 'Reboot',
operationType: 'reboot-system',
sms: undefined,
});
await expect(
service.update('editor', reboot.id, reboot.version, {
retryPolicy: { maxRetries: 1, intervalSeconds: 60 },
}),
).rejects.toThrow(/reboot/i);
db.close();
});
it('does not delete the winning SMS secret when concurrent updates race', async () => {
const { db, repository, service, store } = fixture();
const created = await service.create('operator', base);
store.blockRotations = true;
const results = await Promise.allSettled([
service.update('editor-a', created.id, created.version, {
sms: { recipients: ['13700137000'], content: 'Update A' },
}),
service.update('editor-b', created.id, created.version, {
sms: { recipients: ['13600136000'], content: 'Update B' },
}),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
const currentReference = repository.getSmsSecretReference(created.id);
expect(currentReference).toBeDefined();
expect(currentReference ? store.values.has(currentReference) : false).toBe(true);
expect(store.values).toHaveLength(1);
db.close();
});
it('duplicates configuration and SMS secrets into a disabled independent task', async () => {
const { db, service, store } = fixture();
const created = await service.create('operator', base);
const duplicate = await service.duplicate('operator', created.id, created.version);
expect(duplicate).toMatchObject({
id: 'task-2',
name: 'Morning SMS copy',
operationType: 'send-sms',
enabled: false,
version: 1,
sms: { configured: true, recipientCount: 2 },
});
expect(store.setCalls).toHaveLength(2);
expect(store.setCalls[1]?.value).toBe(store.setCalls[0]?.value);
db.close();
});
});
@@ -0,0 +1,220 @@
import { randomUUID } from 'node:crypto';
import {
parseCreateScheduledTaskRequest,
parseUpdateScheduledTaskRequest,
type CreateScheduledTaskRequest,
type ScheduledTask,
type ScheduledSmsInput,
} from '@multi-simadmin/contracts';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { nextOccurrence, previewCron } from './schedule-time.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
export interface ScheduledTaskServiceOptions {
readonly repository: ScheduledTaskRepository;
readonly store: SecretStore;
readonly now?: () => Date;
readonly id?: () => string;
readonly secretSlot?: () => string;
}
export class ScheduledTaskService {
private readonly repository: ScheduledTaskRepository;
private readonly store: SecretStore;
private readonly now: () => Date;
private readonly id: () => string;
private readonly secretSlot: () => string;
constructor(options: ScheduledTaskServiceOptions) {
this.repository = options.repository;
this.store = options.store;
this.now = options.now ?? (() => new Date());
this.id = options.id ?? randomUUID;
this.secretSlot = options.secretSlot ?? randomUUID;
}
async create(actor: string, value: unknown): Promise<ScheduledTask> {
const task = parseCreateScheduledTaskRequest(value);
const now = this.now();
previewCron(task.cronExpression, 1, now);
const id = this.id();
let smsSecretReference: string | undefined;
if (task.sms) {
smsSecretReference = await this.store.set(
{ instanceId: id, purpose: 'scheduled-sms' },
JSON.stringify(task.sms),
);
}
try {
return this.repository.create({
id,
task,
...(smsSecretReference ? { smsSecretReference } : {}),
createdBy: actor,
now: now.toISOString(),
nextDueAt: this.nextDueAt(task, now),
});
} catch (error) {
if (smsSecretReference) await this.store.delete(smsSecretReference).catch(() => false);
throw error;
}
}
async update(actor: string, id: string, version: number, value: unknown): Promise<ScheduledTask> {
const source = this.updateObject(value);
const change = parseUpdateScheduledTaskRequest({ ...source, version });
const current = this.repository.get(id);
if (!current || current.version !== version)
throw new Error('Scheduled task was not found or version changed');
const oldReference = this.repository.getSmsSecretReference(id);
const operationType = change.operationType ?? current.operationType;
let smsPayload: ScheduledSmsInput | undefined;
if (operationType === 'send-sms') {
if (change.sms === null) throw new TypeError('SMS configuration is required');
if (change.sms) smsPayload = change.sms;
else smsPayload = await this.readSms(oldReference);
} else if (change.sms) {
throw new TypeError('SMS is only valid for send-sms');
}
const merged = parseCreateScheduledTaskRequest({
name: change.name ?? current.name,
operationType,
cronExpression: change.cronExpression ?? current.cronExpression,
timezone: change.timezone ?? current.timezone,
targetSelector: change.targetSelector ?? current.targetSelector,
...(smsPayload ? { sms: smsPayload } : {}),
...(change.effectiveStartAt === null
? {}
: change.effectiveStartAt
? { effectiveStartAt: change.effectiveStartAt }
: current.effectiveStartAt
? { effectiveStartAt: current.effectiveStartAt }
: {}),
...(change.effectiveEndAt === null
? {}
: change.effectiveEndAt
? { effectiveEndAt: change.effectiveEndAt }
: current.effectiveEndAt
? { effectiveEndAt: current.effectiveEndAt }
: {}),
misfirePolicy: change.misfirePolicy ?? current.misfirePolicy,
overlapPolicy: change.overlapPolicy ?? current.overlapPolicy,
retryPolicy: change.retryPolicy ?? current.retryPolicy,
enabled: change.enabled ?? current.enabled,
});
const now = this.now();
previewCron(merged.cronExpression, 1, now);
let nextReference = operationType === 'send-sms' ? oldReference : undefined;
let wroteReference: string | undefined;
if (operationType === 'send-sms' && change.sms) {
wroteReference = await this.store.set(
{ instanceId: id, purpose: 'scheduled-sms', slot: this.secretSlot() },
JSON.stringify(change.sms),
);
nextReference = wroteReference;
}
if (operationType === 'send-sms' && !nextReference)
throw new Error('Scheduled SMS secret is missing');
try {
const updated = this.repository.update({
id,
version,
task: merged,
...(nextReference ? { smsSecretReference: nextReference } : {}),
updatedBy: actor,
now: now.toISOString(),
nextDueAt: this.nextDueAt(merged, now),
});
if (oldReference && oldReference !== nextReference)
await this.store.delete(oldReference).catch(() => false);
return updated;
} catch (error) {
if (wroteReference) await this.store.delete(wroteReference).catch(() => false);
throw error;
}
}
async duplicate(actor: string, id: string, version: number): Promise<ScheduledTask> {
const current = this.repository.get(id);
if (!current || current.version !== version)
throw new Error('Scheduled task was not found or version changed');
const smsPayload =
current.operationType === 'send-sms'
? await this.readSms(this.repository.getSmsSecretReference(id))
: undefined;
const suffix = ' copy';
return this.create(actor, {
name: `${current.name.slice(0, 120 - suffix.length)}${suffix}`,
operationType: current.operationType,
cronExpression: current.cronExpression,
timezone: current.timezone,
targetSelector: current.targetSelector,
...(smsPayload ? { sms: smsPayload } : {}),
...(current.effectiveStartAt ? { effectiveStartAt: current.effectiveStartAt } : {}),
...(current.effectiveEndAt ? { effectiveEndAt: current.effectiveEndAt } : {}),
misfirePolicy: current.misfirePolicy,
overlapPolicy: current.overlapPolicy,
retryPolicy: current.retryPolicy,
enabled: false,
});
}
get(id: string): ScheduledTask | null {
return this.repository.get(id);
}
list(): readonly ScheduledTask[] {
return this.repository.list();
}
preview(cronExpression: string, count = 5, from = this.now()): readonly string[] {
return previewCron(cronExpression, count, from);
}
setEnabled(actor: string, id: string, version: number, enabled: boolean): ScheduledTask {
return this.repository.setEnabled(id, version, enabled, actor, this.now().toISOString());
}
async remove(actor: string, id: string, version: number): Promise<void> {
const reference = this.repository.getSmsSecretReference(id);
this.repository.softDelete(id, version, actor, this.now().toISOString());
if (reference) await this.store.delete(reference).catch(() => false);
}
private updateObject(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError('Schedule update must be an object');
return value as Record<string, unknown>;
}
private async readSms(reference: string | undefined): Promise<ScheduledSmsInput> {
if (!reference) throw new Error('Scheduled SMS secret is missing');
const value = await this.store.get(reference);
if (!value) throw new Error('Scheduled SMS secret is missing');
try {
const parsed = JSON.parse(value) as unknown;
return parseCreateScheduledTaskRequest({
name: 'SMS validation',
operationType: 'send-sms',
cronExpression: '0 0 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['secret-validation'] },
sms: parsed,
}).sms as ScheduledSmsInput;
} catch {
throw new Error('Scheduled SMS secret is invalid');
}
}
private nextDueAt(task: CreateScheduledTaskRequest, now: Date): string {
const anchor = task.effectiveStartAt
? new Date(Math.max(now.getTime(), Date.parse(task.effectiveStartAt)))
: now;
return nextOccurrence(task.cronExpression, anchor);
}
}
@@ -0,0 +1,321 @@
import Database from 'better-sqlite3';
import { describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { SchedulerCoordinator } from './scheduler-coordinator.js';
import type { ScheduledTask } from '@multi-simadmin/contracts';
import type { ResolvedTarget } from './target-resolver.js';
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const now = '2026-07-30T01:00:00.000Z';
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run('a', 'Alpha', 'http://10.0.0.1', 1, 2, now, now);
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run('b', 'Beta', 'http://10.0.0.2', 1, 3, now, now);
const capability = db.prepare(
"INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,'postServiceRestart','supported',?,?,?)",
);
capability.run('a', now, now, now);
capability.run('b', now, now, now);
const repository = new ScheduledTaskRepository(db);
repository.create({
id: 'task-1',
task: {
name: 'Restart lab',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] },
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
},
createdBy: 'operator',
now,
nextDueAt: now,
});
return { db, repository };
}
describe('SchedulerCoordinator', () => {
it('resolves multiple targets, persists the snapshot, and finishes a manual run', async () => {
const { db, repository } = fixture();
const dispatch = vi.fn<
(
task: ScheduledTask,
targets: readonly ResolvedTarget[],
) => Promise<{ outcome: 'succeeded'; jobIds: string[] }>
>(async () => ({
outcome: 'succeeded',
jobIds: ['job-a', 'job-b'],
}));
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'run-1',
});
const run = await coordinator.runNow('task-1', 'operator', 'request-1');
expect(run).toMatchObject({
id: 'run-1',
triggerSource: 'manual',
targetSnapshot: ['a', 'b'],
outcome: 'succeeded',
jobIds: ['job-a', 'job-b'],
});
expect(dispatch.mock.calls[0]?.[1]).toEqual([
{ id: 'a', revision: 2 },
{ id: 'b', revision: 3 },
]);
db.close();
});
it('does not manually dispatch when a selected target lacks the required capability', async () => {
const { db, repository } = fixture();
db.prepare(
"UPDATE capabilities SET state = 'unsupported' WHERE instance_id = 'b' AND operation_id = 'postServiceRestart'",
).run();
const dispatch = vi.fn();
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'manual-capability-run',
});
const run = await coordinator.runNow('task-1', 'operator', 'request-1');
expect(run).toMatchObject({ outcome: 'needs-attention', targetSnapshot: ['a', 'b'] });
expect(dispatch).not.toHaveBeenCalled();
db.close();
});
it('records no-targets without dispatching and prevents duplicate scheduled claims', async () => {
const { db, repository } = fixture();
db.prepare('UPDATE instances SET enabled = 0').run();
const dispatch = vi.fn();
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'run-1',
});
const first = await coordinator.tick();
const second = await coordinator.tick();
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({ outcome: 'no-targets' });
expect(second).toHaveLength(0);
expect(dispatch).not.toHaveBeenCalled();
db.close();
});
it('records needs-attention when a matched target loses its required capability', async () => {
const { db, repository } = fixture();
db.prepare(
"UPDATE capabilities SET state = 'unsupported' WHERE instance_id = 'b' AND operation_id = 'postServiceRestart'",
).run();
const dispatch = vi.fn();
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'capability-run',
});
const runs = await coordinator.tick();
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
outcome: 'needs-attention',
targetSnapshot: ['a', 'b'],
});
expect(runs[0]?.reason).toContain('b');
expect(dispatch).not.toHaveBeenCalled();
db.close();
});
it('queues one overlapping occurrence, skips a second, and dispatches the queued run later', async () => {
const { db, repository } = fixture();
db.prepare("UPDATE scheduled_tasks SET overlap_policy = 'queue-once'").run();
const task = repository.get('task-1')!;
const taskSnapshot = {
name: task.name,
operationType: task.operationType,
cronExpression: task.cronExpression,
timezone: task.timezone,
targetSelector: task.targetSelector,
misfirePolicy: task.misfirePolicy,
overlapPolicy: task.overlapPolicy,
retryPolicy: task.retryPolicy,
enabled: task.enabled,
};
repository.claimOccurrence({
id: 'active-run',
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt: '2026-07-30T00:00:00.000Z',
claimedAt: '2026-07-30T00:00:00.000Z',
triggerSource: 'manual',
targetSnapshot: ['a'],
taskSnapshot,
});
repository.startRun('active-run', '2026-07-30T00:00:00.000Z');
let now = new Date('2026-07-30T01:00:00.000Z');
let id = 0;
const dispatch = vi.fn(async () => ({ outcome: 'succeeded' as const, jobIds: ['job-1'] }));
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => now,
id: () => `queued-${++id}`,
});
expect(await coordinator.tick()).toHaveLength(0);
expect(repository.getQueuedRun('task-1')).toMatchObject({
id: 'queued-1',
targetSnapshot: ['a', 'b'],
});
now = new Date('2026-07-31T01:00:00.000Z');
const queueFull = await coordinator.tick();
expect(queueFull).toHaveLength(1);
expect(queueFull[0]).toMatchObject({ outcome: 'skipped', reason: 'Overlap queue is full' });
expect(dispatch).not.toHaveBeenCalled();
repository.finishRun('active-run', {
outcome: 'succeeded',
jobIds: [],
finishedAt: now.toISOString(),
});
const completed = await coordinator.tick();
expect(completed[0]).toMatchObject({ id: 'queued-1', outcome: 'succeeded' });
expect(dispatch).toHaveBeenCalledTimes(1);
db.close();
});
it('records and advances an occurrence outside the effective window', async () => {
const { db, repository } = fixture();
db.prepare("UPDATE scheduled_tasks SET effective_end_at = '2026-07-29T23:59:00.000Z'").run();
const dispatch = vi.fn();
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'window-skip',
});
const runs = await coordinator.tick();
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
outcome: 'skipped',
reason: 'Occurrence is outside the effective window',
});
expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-31T01:00:00.000Z');
expect(dispatch).not.toHaveBeenCalled();
db.close();
});
it('does not advance a due occurrence when its claim cannot be persisted', async () => {
const { db, repository } = fixture();
vi.spyOn(repository, 'claimOccurrence').mockImplementationOnce(() => {
throw new Error('claim failed');
});
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch: vi.fn(),
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'failed-claim',
});
await expect(coordinator.tick()).rejects.toThrow('claim failed');
expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z');
db.close();
});
it('executes at most the queued occurrence for a task during one tick', async () => {
const { db, repository } = fixture();
const task = repository.get('task-1')!;
repository.claimOccurrence({
id: 'queued-run',
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt: '2026-07-29T01:00:00.000Z',
claimedAt: '2026-07-29T01:00:00.000Z',
triggerSource: 'scheduled',
targetSnapshot: ['a'],
taskSnapshot: {
name: task.name,
operationType: task.operationType,
cronExpression: task.cronExpression,
timezone: task.timezone,
targetSelector: task.targetSelector,
misfirePolicy: task.misfirePolicy,
overlapPolicy: task.overlapPolicy,
retryPolicy: task.retryPolicy,
enabled: task.enabled,
},
});
let id = 0;
const dispatch = vi.fn(async () => ({ outcome: 'succeeded' as const, jobIds: ['job-1'] }));
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => `run-${++id}`,
});
const completed = await coordinator.tick();
expect(completed).toHaveLength(1);
expect(completed[0]?.id).toBe('queued-run');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z');
db.close();
});
it('does not reschedule after stop while a tick is still running', async () => {
vi.useFakeTimers();
const { db, repository } = fixture();
let finishDispatch!: (value: { outcome: 'succeeded'; jobIds: string[] }) => void;
const dispatch = vi.fn(
() =>
new Promise<{ outcome: 'succeeded'; jobIds: string[] }>((resolve) => {
finishDispatch = resolve;
}),
);
const coordinator = new SchedulerCoordinator({
db,
repository,
dispatch,
now: () => new Date('2026-07-30T01:00:00.000Z'),
id: () => 'in-flight-run',
intervalMs: 1_000,
});
try {
coordinator.start();
expect(dispatch).toHaveBeenCalledTimes(1);
coordinator.stop();
finishDispatch({ outcome: 'succeeded', jobIds: ['job-1'] });
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
} finally {
coordinator.stop();
vi.useRealTimers();
db.close();
}
});
});
@@ -0,0 +1,326 @@
import { randomUUID } from 'node:crypto';
import type {
CreateScheduledTaskRequest,
ScheduledRun,
ScheduledRunOutcome,
ScheduledTask,
} from '@multi-simadmin/contracts';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
import { nextOccurrence, reconcileOccurrence } from './schedule-time.js';
import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { resolveOperationTargets, type ResolvedTarget } from './target-resolver.js';
export interface DispatchResult {
readonly outcome: ScheduledRunOutcome;
readonly reason?: string;
readonly jobIds: readonly string[];
}
export type ScheduledDispatch = (
task: ScheduledTask,
targets: readonly ResolvedTarget[],
run: ScheduledRun,
context: { readonly actor: string; readonly requestId: string },
) => Promise<DispatchResult>;
export interface SchedulerCoordinatorOptions {
readonly db: SqliteDatabase;
readonly repository: ScheduledTaskRepository;
readonly dispatch: ScheduledDispatch;
readonly now?: () => Date;
readonly id?: () => string;
readonly intervalMs?: number;
}
function snapshot(task: ScheduledTask): CreateScheduledTaskRequest {
return {
name: task.name,
operationType: task.operationType,
cronExpression: task.cronExpression,
timezone: task.timezone,
targetSelector: task.targetSelector,
...(task.effectiveStartAt ? { effectiveStartAt: task.effectiveStartAt } : {}),
...(task.effectiveEndAt ? { effectiveEndAt: task.effectiveEndAt } : {}),
misfirePolicy: task.misfirePolicy,
overlapPolicy: task.overlapPolicy,
retryPolicy: task.retryPolicy,
enabled: task.enabled,
};
}
export class SchedulerCoordinator {
private readonly now: () => Date;
private readonly id: () => string;
private readonly intervalMs: number;
private timer: ReturnType<typeof setTimeout> | undefined;
private started = false;
private lifecycle = 0;
constructor(private readonly options: SchedulerCoordinatorOptions) {
this.now = options.now ?? (() => new Date());
this.id = options.id ?? randomUUID;
this.intervalMs = options.intervalMs ?? 30_000;
}
start(): void {
if (this.started) return;
this.started = true;
const lifecycle = ++this.lifecycle;
const schedule = () => {
if (!this.started || lifecycle !== this.lifecycle) return;
this.timer = setTimeout(() => {
this.timer = undefined;
void this.tick().finally(schedule);
}, this.intervalMs);
this.timer.unref?.();
};
void this.tick().finally(schedule);
}
stop(): void {
this.started = false;
this.lifecycle += 1;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
}
async runNow(taskId: string, actor: string, requestId: string): Promise<ScheduledRun> {
const task = this.options.repository.get(taskId);
if (!task) throw new Error('Scheduled task was not found');
return this.runOccurrence(task, this.now().toISOString(), 'manual', actor, requestId);
}
async tick(): Promise<readonly ScheduledRun[]> {
const now = this.now();
const nowIso = now.toISOString();
const completed: ScheduledRun[] = [];
for (const task of this.options.repository.list()) {
const queued = this.options.repository.getQueuedRun(task.id);
if (queued && !this.options.repository.hasActiveRun(task.id)) {
if (queued.scheduleVersion !== task.version) {
completed.push(
this.options.repository.finishRun(queued.id, {
outcome: 'needs-attention',
reason: 'Queued occurrence belongs to an outdated schedule version',
jobIds: [],
finishedAt: nowIso,
}),
);
} else if (!task.enabled) {
completed.push(
this.options.repository.finishRun(queued.id, {
outcome: 'skipped',
reason: 'Schedule was disabled while the occurrence was queued',
jobIds: [],
finishedAt: nowIso,
}),
);
} else {
const resolution = resolveOperationTargets(
this.options.db,
{ mode: 'fixed', instanceIds: queued.targetSnapshot },
task.operationType,
);
if (resolution.unavailableInstanceIds.length > 0) {
completed.push(
this.options.repository.finishRun(queued.id, {
outcome: 'needs-attention',
reason: this.capabilityReason(resolution.unavailableInstanceIds),
jobIds: [],
finishedAt: nowIso,
}),
);
continue;
}
completed.push(
await this.executeClaimed(
task,
queued,
resolution.targets,
'scheduled-automation',
`queue:${queued.id}`,
),
);
}
continue;
}
if (!task.enabled || !task.nextDueAt || task.nextDueAt > nowIso) continue;
const outsideWindow =
(task.effectiveStartAt !== undefined && nowIso < task.effectiveStartAt) ||
(task.effectiveEndAt !== undefined && nowIso > task.effectiveEndAt);
if (outsideWindow) {
const anchor =
task.effectiveStartAt && nowIso < task.effectiveStartAt
? new Date(task.effectiveStartAt)
: now;
const nextDueAt = nextOccurrence(task.cronExpression, anchor);
const claimed = this.options.repository.claimScheduledOccurrence({
id: this.id(),
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt: task.nextDueAt,
nextDueAt,
claimedAt: nowIso,
taskSnapshot: snapshot(task),
overlapPolicy: task.overlapPolicy,
resolveTargets: () => ({ targets: [] }),
terminal: {
outcome: 'skipped',
reason: 'Occurrence is outside the effective window',
},
});
if (claimed) completed.push(claimed.run);
continue;
}
const exact = task.nextDueAt === nowIso;
const reconciled = exact
? {
action: 'run' as const,
dueAt: task.nextDueAt,
nextDueAt: nextOccurrence(task.cronExpression, now),
}
: reconcileOccurrence(
{
cronExpression: task.cronExpression,
nextDueAt: task.nextDueAt,
misfirePolicy: task.misfirePolicy,
},
now,
);
if (reconciled.action === 'wait') continue;
if (reconciled.action === 'skip') {
const claimed = this.options.repository.claimScheduledOccurrence({
id: this.id(),
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt: reconciled.dueAt,
nextDueAt: reconciled.nextDueAt,
claimedAt: nowIso,
taskSnapshot: snapshot(task),
overlapPolicy: task.overlapPolicy,
resolveTargets: () => ({ targets: [] }),
terminal: {
outcome: 'skipped',
reason: 'Missed occurrence skipped by task policy',
},
});
if (claimed) completed.push(claimed.run);
continue;
}
const claimed = this.options.repository.claimScheduledOccurrence({
id: this.id(),
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt: reconciled.dueAt,
nextDueAt: reconciled.nextDueAt,
claimedAt: nowIso,
taskSnapshot: snapshot(task),
overlapPolicy: task.overlapPolicy,
resolveTargets: () => {
const resolution = resolveOperationTargets(
this.options.db,
task.targetSelector,
task.operationType,
);
return {
targets: resolution.targets,
targetSnapshot: resolution.targetSnapshot,
...(resolution.unavailableInstanceIds.length > 0
? {
attentionReason: this.capabilityReason(resolution.unavailableInstanceIds),
}
: {}),
};
},
});
if (!claimed || claimed.disposition === 'queued') continue;
if (claimed.disposition === 'finished') {
completed.push(claimed.run);
continue;
}
completed.push(
await this.executeClaimed(
task,
claimed.run,
claimed.targets,
'scheduled-automation',
`schedule:${task.id}:${reconciled.dueAt}`,
),
);
}
return completed;
}
private async runOccurrence(
task: ScheduledTask,
dueAt: string,
triggerSource: 'scheduled' | 'manual',
actor: string,
requestId: string,
): Promise<ScheduledRun> {
const resolution = resolveOperationTargets(
this.options.db,
task.targetSelector,
task.operationType,
);
const claimed = this.options.repository.claimOccurrence({
id: this.id(),
scheduledTaskId: task.id,
scheduleVersion: task.version,
dueAt,
claimedAt: this.now().toISOString(),
triggerSource,
targetSnapshot: resolution.targetSnapshot,
taskSnapshot: snapshot(task),
});
if (!claimed) throw new Error('Scheduled occurrence was already claimed');
if (resolution.unavailableInstanceIds.length > 0)
return this.options.repository.finishRun(claimed.id, {
outcome: 'needs-attention',
reason: this.capabilityReason(resolution.unavailableInstanceIds),
jobIds: [],
finishedAt: this.now().toISOString(),
});
return this.executeClaimed(task, claimed, resolution.targets, actor, requestId);
}
private capabilityReason(instanceIds: readonly string[]): string {
return `Required capability is unavailable for: ${instanceIds.join(', ')}`;
}
private async executeClaimed(
task: ScheduledTask,
claimed: ScheduledRun,
targets: readonly ResolvedTarget[],
actor: string,
requestId: string,
): Promise<ScheduledRun> {
const now = this.now().toISOString();
if (targets.length === 0)
return this.options.repository.finishRun(claimed.id, {
outcome: 'no-targets',
reason: 'No enabled instances matched the current selector',
jobIds: [],
finishedAt: now,
});
this.options.repository.startRun(claimed.id, now);
try {
const result = await this.options.dispatch(task, targets, claimed, { actor, requestId });
return this.options.repository.finishRun(claimed.id, {
outcome: result.outcome,
...(result.reason ? { reason: result.reason } : {}),
jobIds: result.jobIds,
finishedAt: this.now().toISOString(),
});
} catch {
return this.options.repository.finishRun(claimed.id, {
outcome: 'failed',
reason: 'Scheduled dispatch failed',
jobIds: [],
finishedAt: this.now().toISOString(),
});
}
}
}
@@ -0,0 +1,85 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { resolveOperationTargets, resolveTargets } from './target-resolver.js';
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const now = '2026-07-30T00:00:00.000Z';
const insert = db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
);
insert.run('a', 'Alpha', 'http://10.0.0.1', 1, 3, now, now);
insert.run('b', 'Beta', 'http://10.0.0.2', 1, 2, now, now);
insert.run('c', 'Disabled', 'http://10.0.0.3', 0, 1, now, now);
const tag = db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)');
tag.run('a', 'lab', now);
tag.run('a', 'east', now);
tag.run('b', 'lab', now);
tag.run('c', 'east', now);
return db;
}
describe('resolveTargets', () => {
it('resolves enabled fixed targets in selector order with current revisions', () => {
const db = fixture();
expect(resolveTargets(db, { mode: 'fixed', instanceIds: ['b', 'c', 'a'] })).toEqual([
{ id: 'b', revision: 2 },
{ id: 'a', revision: 3 },
]);
db.close();
});
it('resolves dynamic all and any tag matches on every call', () => {
const db = fixture();
expect(resolveTargets(db, { mode: 'tags', match: 'all', tags: ['lab', 'east'] })).toEqual([
{ id: 'a', revision: 3 },
]);
expect(resolveTargets(db, { mode: 'tags', match: 'any', tags: ['lab', 'east'] })).toEqual([
{ id: 'a', revision: 3 },
{ id: 'b', revision: 2 },
]);
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(
'd',
'Dynamic',
'http://10.0.0.4',
1,
1,
'2026-07-30T00:00:00.000Z',
'2026-07-30T00:00:00.000Z',
);
db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run(
'd',
'east',
'2026-07-30T00:00:00.000Z',
);
expect(
resolveTargets(db, { mode: 'tags', match: 'any', tags: ['east'] }).map((x) => x.id),
).toEqual(['a', 'd']);
db.close();
});
it('separates targets whose required operation capability is unavailable', () => {
const db = fixture();
const now = '2026-07-30T00:00:00.000Z';
db.prepare(
'INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,?,?,?,?,?)',
).run('a', 'postServiceRestart', 'supported', now, now, now);
db.prepare(
'INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,?,?,?,?,?)',
).run('b', 'postServiceRestart', 'unsupported', now, now, now);
expect(
resolveOperationTargets(db, { mode: 'fixed', instanceIds: ['a', 'b'] }, 'restart-service'),
).toEqual({
targets: [{ id: 'a', revision: 3 }],
targetSnapshot: ['a', 'b'],
unavailableInstanceIds: ['b'],
});
db.close();
});
});
@@ -0,0 +1,91 @@
import type { ScheduledOperationType, ScheduleTargetSelector } from '@multi-simadmin/contracts';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
export interface ResolvedTarget {
readonly id: string;
readonly revision: number;
}
interface TargetRow {
id: string;
config_revision: number;
}
export interface OperationTargetResolution {
readonly targets: readonly ResolvedTarget[];
readonly targetSnapshot: readonly string[];
readonly unavailableInstanceIds: readonly string[];
}
const operationIds: Record<ScheduledOperationType, string> = {
'restart-service': 'postServiceRestart',
'reboot-system': 'postSystemReboot',
'send-sms': 'postSmsSend',
};
export function resolveTargets(
db: SqliteDatabase,
selector: ScheduleTargetSelector,
): readonly ResolvedTarget[] {
if (selector.mode === 'fixed') {
const placeholders = selector.instanceIds.map(() => '?').join(',');
const rows = db
.prepare(
`SELECT id, config_revision FROM instances
WHERE enabled = 1 AND id IN (${placeholders})`,
)
.all(...selector.instanceIds) as TargetRow[];
const byId = new Map(rows.map((row) => [row.id, row]));
return selector.instanceIds.flatMap((id) => {
const row = byId.get(id);
return row ? [{ id: row.id, revision: row.config_revision }] : [];
});
}
const placeholders = selector.tags.map(() => '?').join(',');
const comparison = selector.match === 'all' ? '= ?' : '> 0';
const parameters: Array<string | number> = [...selector.tags];
if (selector.match === 'all') parameters.push(selector.tags.length);
const rows = db
.prepare(
`SELECT i.id, i.config_revision
FROM instances i
JOIN instance_tags t ON t.instance_id = i.id
WHERE i.enabled = 1 AND t.tag IN (${placeholders})
GROUP BY i.id, i.config_revision
HAVING COUNT(DISTINCT t.tag) ${comparison}
ORDER BY i.id ASC`,
)
.all(...parameters) as TargetRow[];
return rows.map((row) => ({ id: row.id, revision: row.config_revision }));
}
export function resolveOperationTargets(
db: SqliteDatabase,
selector: ScheduleTargetSelector,
operationType: ScheduledOperationType,
): OperationTargetResolution {
const candidates = resolveTargets(db, selector);
if (candidates.length === 0)
return { targets: [], targetSnapshot: [], unavailableInstanceIds: [] };
const placeholders = candidates.map(() => '?').join(',');
const rows = db
.prepare(
`SELECT instance_id, state FROM capabilities
WHERE operation_id = ? AND instance_id IN (${placeholders})`,
)
.all(operationIds[operationType], ...candidates.map((target) => target.id)) as Array<{
instance_id: string;
state: string;
}>;
const states = new Map(rows.map((row) => [row.instance_id, row.state]));
const availableStates = new Set(['supported', 'auth-required', 'degraded']);
return {
targets: candidates.filter((target) => availableStates.has(states.get(target.id) ?? 'unknown')),
targetSnapshot: candidates.map((target) => target.id),
unavailableInstanceIds: candidates
.filter((target) => !availableStates.has(states.get(target.id) ?? 'unknown'))
.map((target) => target.id),
};
}
@@ -4,12 +4,18 @@ import {
InstanceMessageService,
MessageServiceError,
parseMessageList,
validMessageContent,
} 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('accepts the automation contract maximum of 2,000 SMS characters', () => {
expect(validMessageContent('字'.repeat(2_000))).toBe(true);
expect(validMessageContent('字'.repeat(2_001))).toBe(false);
});
it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => {
const messages = parseMessageList(
{
@@ -51,6 +57,43 @@ describe('InstanceMessageService', () => {
expect(JSON.stringify(messages)).not.toContain('secret');
});
it('accepts the current SimAdmin SMS shape without the removed transport field', () => {
expect(
parseMessageList(
{
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 42,
direction: 'incoming',
phone_number: '10086',
content: 'current payload',
timestamp: '2026-07-29 12:30:00',
status: 'received',
},
],
},
}),
},
10,
),
).toEqual([
{
id: '42',
direction: 'incoming',
phoneNumber: '10086',
content: 'current payload',
timestamp: '2026-07-29 12:30:00',
status: 'received',
transport: 'modem',
},
]);
});
it('uses exact owner, bounded query and matching optional session cookie', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', instance.origin, 'simadmin_session=opaque');
@@ -55,8 +55,8 @@ export function validPhoneNumber(value: string): boolean {
export function validMessageContent(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 1600 &&
Buffer.byteLength(value, 'utf8') <= 6400 &&
value.length <= 2000 &&
Buffer.byteLength(value, 'utf8') <= 8000 &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
);
}
@@ -87,10 +87,12 @@ export function parseMessageList(
const value = record(item);
const id = bounded(value?.id, 128);
const phoneNumber = bounded(value?.phone_number, 32);
const content = bounded(value?.content, 1600);
const content = bounded(value?.content, 2000);
const timestamp = bounded(value?.timestamp, 64);
const status = bounded(value?.status, 32);
const transport = bounded(value?.transport, 32);
// Current SimAdmin SmsMessage has no transport field; older captures sometimes did.
// Keep the aggregate contract stable without dropping every current message.
const transport = bounded(value?.transport, 32) ?? 'modem';
const rawDirection = bounded(value?.direction, 16);
if (
!id ||
@@ -98,8 +100,7 @@ export function parseMessageList(
!validPhoneNumber(phoneNumber) ||
content === undefined ||
timestamp === undefined ||
status === undefined ||
transport === undefined
status === undefined
)
continue;
const direction: MessageDirection =
@@ -133,6 +134,11 @@ export class InstanceMessageService {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
readonly ensureSession?: (
instanceId: string,
origin: string,
force?: boolean,
) => Promise<void>;
},
) {}
@@ -142,7 +148,14 @@ export class InstanceMessageService {
const session = this.options.sessions.sessionFor(instanceId);
if (session && session.origin !== instance.origin)
throw new MessageServiceError('SESSION_INVALID');
return { instance, session };
if (!session && this.options.ensureSession) {
try {
await this.options.ensureSession(instanceId, instance.origin);
} catch {
// No saved credential means this may be a passwordless instance; read anonymously.
}
}
return { instance, session: this.options.sessions.sessionFor(instanceId) };
}
async list(
@@ -163,11 +176,17 @@ export class InstanceMessageService {
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 } : {}) },
});
const request = (cookie?: string) =>
this.options.request({
url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`,
method: 'GET',
headers: { accept: 'application/json', ...(cookie ? { cookie } : {}) },
});
let response = await request(session?.cookie);
if ([401, 403].includes(response.status) && this.options.ensureSession) {
await this.options.ensureSession(instanceId, instance.origin, true);
response = await request(this.options.sessions.sessionFor(instanceId)?.cookie);
}
return { messages: parseMessageList(response, query.limit) };
}
@@ -175,16 +194,22 @@ export class InstanceMessageService {
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 },
});
const request = (cookie?: string) =>
this.options.request({
url: `${instance.origin}/api/sms/send`,
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
},
sms: { phoneNumber: input.phoneNumber, content: input.content },
});
let response = await request(session?.cookie);
if ([401, 403].includes(response.status) && this.options.ensureSession) {
await this.options.ensureSession(instanceId, instance.origin, true);
response = await request(this.options.sessions.sessionFor(instanceId)?.cookie);
}
parseSendSuccess(response);
return { sent: true };
}
@@ -130,6 +130,7 @@ describe('SecureOperationExecution generic R2 slice', () => {
query: '',
contentType: '',
body: undefined,
instanceId: 'i-1',
});
await expectCode(
execution.execute(
@@ -244,10 +245,11 @@ describe('SecureOperationExecution R3 restart slice', () => {
query: '',
contentType: '',
body: undefined,
instanceId: 'i-1',
});
expect(db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id)).toEqual({
risk_level: 'R3',
});
expect(
db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id),
).toEqual({ risk_level: 'R3' });
});
it('prepares and executes system reboot only with fixed delay_seconds=3', async () => {
@@ -283,6 +285,7 @@ describe('SecureOperationExecution R3 restart slice', () => {
query: '',
contentType: 'application/json',
body: '{"delay_seconds":3}',
instanceId: 'i-1',
});
});
});
@@ -71,6 +71,8 @@ export interface SafeOperationTransportRequest {
readonly contentType: string;
/** Serialized JSON body for audited JSON operations; undefined for zero-body POSTs. */
readonly body: string | undefined;
/** Bound target used only for origin-scoped session attachment; never logged. */
readonly instanceId: string;
}
export interface SafeOperationTransport {
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
@@ -199,7 +201,8 @@ export class SecureOperationExecution {
let contentType = '';
let serializedBody: string | undefined;
if (ZERO_BODY_OPERATIONS.has(descriptor.operationId)) {
if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0) this.validation();
if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0)
this.validation();
} else if (descriptor.operationId === SYSTEM_REBOOT_OPERATION) {
if (descriptor.requestContentType !== 'application/json' || parameters.fields.length !== 1)
this.validation();
@@ -412,6 +415,7 @@ export class SecureOperationExecution {
bound.operation_id === SYSTEM_REBOOT_OPERATION
? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS })
: undefined,
instanceId: bound.target_instance_id,
});
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { InstanceResourceService, parseSim, parseStats } from './instance-resource-service.js';
import {
InstanceResourceService,
parseHealth,
parseSim,
parseStats,
} from './instance-resource-service.js';
const response = (body: unknown) => ({
status: 200,
@@ -27,6 +32,27 @@ describe('instance resource allowlist parsing', () => {
).toEqual({ cpuPercent: 23.4, memoryPercent: 67.8, maxTemperatureCelsius: 52.6 });
});
it('falls back to version metadata exposed by stats on older SimAdmin builds', () => {
expect(
parseStats(
response({
data: {
cpu_load: { load_percent: 10 },
system: { app_version: '1.8.7', architecture: 'aarch64' },
},
}),
),
).toMatchObject({ cpuPercent: 10, version: '1.8.7', platform: 'aarch64' });
});
it('extracts the upstream SimAdmin version from the health endpoint', () => {
expect(
parseHealth(
response({ status: 'ok', version: '1.9.4', platform: 'linux-aarch64', secret: 'drop' }),
),
).toEqual({ version: '1.9.4', platform: 'linux-aarch64' });
});
it('extracts, validates, deduplicates and bounds phone numbers only', () => {
expect(
parseSim(
@@ -51,7 +77,9 @@ describe('instance resource allowlist parsing', () => {
return response(
request.url.endsWith('/api/stats')
? { data: { cpu_load: { load_percent: 12 }, memory: { used_percent: 34 } } }
: { data: { phone_numbers: ['13800000000'] } },
: request.url.endsWith('/api/sim')
? { data: { phone_numbers: ['13800000000'] } }
: { status: 'ok', version: '2.0.1', platform: 'linux' },
);
},
});
@@ -59,10 +87,13 @@ describe('instance resource allowlist parsing', () => {
cpuPercent: 12,
memoryPercent: 34,
phoneNumbers: ['13800000000'],
version: '2.0.1',
platform: 'linux',
});
expect(requests.map((request) => request.headers)).toEqual([
{ accept: 'application/json' },
{ accept: 'application/json' },
{ accept: 'application/json' },
]);
});
@@ -10,6 +10,8 @@ export interface InstanceResources {
readonly memoryPercent?: number;
readonly maxTemperatureCelsius?: number;
readonly phoneNumbers?: readonly string[];
readonly version?: string;
readonly platform?: string;
}
const MAX_BODY_BYTES = 32_768;
@@ -32,6 +34,31 @@ function data(response: UpstreamResponse): Record<string, unknown> | undefined {
}
}
const safeText = (value: unknown, maximum = 128): string | undefined =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!/[\u0000-\u001f\u007f]/u.test(value)
? value
: undefined;
export function parseHealth(response: UpstreamResponse): InstanceResources {
if (response.status < 200 || response.status >= 300) return {};
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return {};
try {
const root = record(JSON.parse(response.body));
if (!root) return {};
const version = safeText(root.version);
const platform = safeText(root.platform);
return {
...(version ? { version } : {}),
...(platform ? { platform } : {}),
};
} catch {
return {};
}
}
export function parseStats(response: UpstreamResponse): InstanceResources {
const value = data(response);
if (!value) return {};
@@ -43,10 +70,20 @@ export function parseStats(response: UpstreamResponse): InstanceResources {
.filter((item): item is number => item !== undefined)
: [];
const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined;
const system = record(value.system);
const version =
safeText(value.version) ??
safeText(value.current_version) ??
safeText(system?.version) ??
safeText(system?.app_version);
const platform =
safeText(value.platform) ?? safeText(system?.platform) ?? safeText(system?.architecture);
return {
...(cpuPercent === undefined ? {} : { cpuPercent }),
...(memoryPercent === undefined ? {} : { memoryPercent }),
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
...(version ? { version } : {}),
...(platform ? { platform } : {}),
};
}
@@ -69,6 +106,11 @@ export class InstanceResourceService {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
readonly ensureSession?: (
instanceId: string,
origin: string,
force?: boolean,
) => Promise<void>;
},
) {}
@@ -79,19 +121,48 @@ export class InstanceResourceService {
]);
if (!instance) return {};
if (session && session.origin !== instance.origin) return {};
const get = (path: '/api/stats' | '/api/sim') =>
this.options.request({
url: `${instance.origin}${path}`,
method: 'GET',
headers: {
accept: 'application/json',
...(session ? { cookie: session.cookie } : {}),
},
});
const [stats, sim] = await Promise.allSettled([get('/api/stats'), get('/api/sim')]);
if (!session && this.options.ensureSession) {
try {
await this.options.ensureSession(instanceId, instance.origin);
} catch {
// Passwordless and temporarily unavailable credentials still permit anonymous reads.
}
}
const paths = ['/api/stats', '/api/sim', '/api/health'] as const;
const readAll = () => {
const activeSession = this.options.sessions.sessionFor(instanceId);
return Promise.allSettled(
paths.map((path) =>
this.options.request({
url: `${instance.origin}${path}`,
method: 'GET',
headers: {
accept: 'application/json',
...(activeSession?.origin === instance.origin
? { cookie: activeSession.cookie }
: {}),
},
}),
),
);
};
let results = await readAll();
const unauthorized = results.some(
(result) => result.status === 'fulfilled' && [401, 403].includes(result.value.status),
);
if (unauthorized && this.options.ensureSession) {
try {
await this.options.ensureSession(instanceId, instance.origin, true);
results = await readAll();
} catch {
// Return the safe partial result when re-authentication is unavailable.
}
}
const [stats, sim, health] = results;
return {
...(stats.status === 'fulfilled' ? parseStats(stats.value) : {}),
...(sim.status === 'fulfilled' ? parseSim(sim.value) : {}),
...(stats?.status === 'fulfilled' ? parseStats(stats.value) : {}),
...(sim?.status === 'fulfilled' ? parseSim(sim.value) : {}),
...(health?.status === 'fulfilled' ? parseHealth(health.value) : {}),
};
}
}
+242 -11
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { buildControlPlaneApp } from './control-plane.js';
import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
import { SafeInstanceTransport } from './infrastructure/transport/safe-instance-transport.js';
@@ -12,17 +13,83 @@ afterEach(async () => {
for (const db of dbs.splice(0)) db.close();
});
class Store implements SecretStore {
async set() {
return '';
private readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
const account = Buffer.from(
JSON.stringify(
key.slot === undefined
? [key.instanceId, key.purpose]
: [key.instanceId, key.purpose, key.slot],
),
'utf8',
).toString('base64url');
const reference = `keychain://multi-simadmin/${account}`;
this.values.set(reference, value);
return reference;
}
async get() {
return '[REDACTED]';
async get(reference: string) {
return this.values.get(reference);
}
async delete() {
return false;
async delete(reference: string) {
return this.values.delete(reference);
}
}
describe('buildControlPlaneApp', () => {
it('reconciles interrupted scheduled runs before starting the scheduler', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const repository = new ScheduledTaskRepository(db);
const task = {
name: 'Interrupted restart',
operationType: 'restart-service' as const,
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai' as const,
targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] },
misfirePolicy: 'skip' as const,
overlapPolicy: 'skip' as const,
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: false,
};
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt: '2026-07-30T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:00.000Z',
triggerSource: 'scheduled',
targetSnapshot: [],
taskSnapshot: task,
});
repository.startRun('run-1', '2026-07-30T01:00:01.000Z');
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-30T02:00:00.000Z'),
});
expect(repository.getRun('run-1')).toMatchObject({
outcome: 'needs-attention',
finishedAt: '2026-07-30T02:00:00.000Z',
});
await app.close();
});
it('registers the durable event route fail-closed when no authentication dependency is supplied', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
@@ -127,14 +194,178 @@ describe('buildControlPlaneApp', () => {
url: '/api/v1/instances/' + created.json().id + '/login',
payload: { password: 'never-leak-this' },
});
expect(insecureLogin.statusCode).toBe(400);
// Literal private HTTP origins are allowed for LAN instances; secrets still never leak.
expect(insecureLogin.statusCode).toBe(200);
expect(insecureLogin.json()).toMatchObject({
title: 'Bad Request',
status: 400,
code: 'UPSTREAM_INSECURE_AUTH',
authenticated: true,
instanceId: created.json().id,
});
expect(insecureLogin.body).not.toContain('never-leak-this');
expect(insecureLogin.body).not.toContain('192.168.1.10');
expect(insecureLogin.body).not.toContain('simadmin_session');
await app.close();
});
it('automatically logs in with saved credentials before aggregate messages and resource reads', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const calls: Array<{ url: string; cookie?: string }> = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
calls.push({
url: request.url,
...(request.headers.cookie ? { cookie: request.headers.cookie } : {}),
});
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=aggregate-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 7,
direction: 'incoming',
phone_number: '10086',
content: 'hi',
timestamp: '2026-07-29 12:00:00',
status: 'received',
},
],
},
}),
};
if (request.url.endsWith('/api/health'))
return {
status: 200,
headers: {},
body: '{"status":"ok","version":"2.3.4","platform":"linux"}',
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Protected',
origin: 'http://192.168.1.20:3000',
password: { action: 'set', password: '[REDACTED]' },
},
});
const id = created.json().id as string;
const messages = await app.inject({
method: 'GET',
url: `/api/v1/instances/${id}/messages?limit=1&offset=0`,
});
expect(messages.statusCode).toBe(200);
expect(messages.json().messages).toEqual([
expect.objectContaining({ id: '7', phoneNumber: '10086', content: 'hi' }),
]);
const resources = await app.inject({ method: 'GET', url: `/api/v1/instances/${id}/resources` });
expect(resources.statusCode).toBe(200);
expect(resources.json()).toMatchObject({ version: '2.3.4', platform: 'linux' });
expect(calls.filter((call) => call.url.endsWith('/api/auth/login'))).toHaveLength(1);
for (const call of calls.filter((item) => !item.url.endsWith('/api/auth/login')))
expect(call.cookie).toBe('simadmin_session=aggregate-session');
await app.close();
});
it('attaches an origin-bound session cookie when executing restart operations', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const calls: unknown[] = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
calls.push({ kind: 'auth', request });
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque-token' },
body: '',
};
return { status: 200, headers: {}, body: '' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async (origin, cookie) => {
calls.push({ kind: 'restart', origin, cookie });
return { status: 200 };
},
postSystemReboot: async (origin, delaySeconds, cookie) => {
calls.push({ kind: 'reboot', origin, delaySeconds, cookie });
return { status: 200 };
},
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Restart Target',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const revision = created.json().revision as number;
const prepared = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'postServiceRestart',
targets: [{ instanceId, revision }],
parameters: {
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
fields: [],
},
},
});
expect(prepared.statusCode).toBe(200);
const executed = await app.inject({
method: 'POST',
url: '/api/v1/operations/execute',
payload: {
preparationId: prepared.json().id,
confirmationToken: prepared.json().confirmationToken,
},
});
expect(executed.statusCode).toBe(202);
expect(executed.json()).toMatchObject({
operationId: 'postServiceRestart',
status: 'succeeded',
});
expect(calls).toContainEqual({
kind: 'restart',
origin: 'http://192.168.1.10:8080',
cookie: 'simadmin_session=opaque-token',
});
// Auth request body uses the redacted marker; the actual secret must never appear.
expect(JSON.stringify(calls)).toContain('"[REDACTED]"');
expect(JSON.stringify(calls)).not.toMatch(/never-leak|password":"[^[]/);
await app.close();
});
+99 -11
View File
@@ -32,12 +32,21 @@ import { InstanceResourceService } from './application/resources/instance-resour
import { ConsoleAuthService } from './application/auth/console-auth-service.js';
import { registerConsoleAuth } from './interface/http/console-auth-routes.js';
import { InstanceMessageService } from './application/messages/instance-message-service.js';
import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js';
import { ScheduledTaskService } from './application/automation/scheduled-task-service.js';
import { registerAutomationRoutes } from './interface/http/automation-routes.js';
import { ScheduledOperationDispatcher } from './application/automation/scheduled-operation-dispatcher.js';
import { SchedulerCoordinator } from './application/automation/scheduler-coordinator.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string): Promise<{ readonly status: number }>;
postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>;
postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postSystemReboot(
origin: string,
delaySeconds: number,
cookie?: string,
): Promise<{ readonly status: number }>;
}
export interface ControlPlaneOptions {
readonly db: Database.Database;
@@ -54,6 +63,13 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
const eventJournal = new EventJournal(options.db);
const jobs = new JobQueryService(options.db);
const audit = new AuditQueryService(options.db);
const scheduledTasks = new ScheduledTaskRepository(options.db);
scheduledTasks.reconcileInterruptedRuns((options.now?.() ?? new Date()).toISOString());
const automation = new ScheduledTaskService({
repository: scheduledTasks,
store: options.store,
...(options.now ? { now: options.now } : {}),
});
const instances = options.now
? new InstanceService({ db: options.db, store: options.store, now: options.now })
: new InstanceService({ db: options.db, store: options.store });
@@ -67,41 +83,91 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
const login = options.now
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
: new InstanceLoginService({ db: options.db, client, resolver });
const pendingLogins = new Map<string, Promise<void>>();
const ensureSession = async (
instanceId: string,
origin: string,
force = false,
): Promise<void> => {
const existing = sessions.sessionFor(instanceId);
if (!force && existing?.origin === origin) return;
if (existing) sessions.clear(instanceId);
const current = pendingLogins.get(instanceId);
if (current) return current;
const pending = login
.login(instanceId)
.then((result) => {
if (!result.authenticated) throw new Error('INSTANCE_AUTHENTICATION_FAILED');
})
.finally(() => pendingLogins.delete(instanceId));
pendingLogins.set(instanceId, pending);
return pending;
};
const resources = new InstanceResourceService({
instances,
sessions,
request: options.upstream.request,
ensureSession,
});
const messages = new InstanceMessageService({
instances,
sessions,
request: options.upstream.request,
ensureSession,
});
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
const login = options.now
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
: new InstanceLoginService({ db: options.db, client, resolver });
const deletion = options.now
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
: new DeleteInstanceOperation({ db: options.db, instances });
deletion.reconcileInterruptedJobs();
const resolveOperationCookie = async (
instanceId: string,
origin: string,
): Promise<string | undefined> => {
// Attach only an origin-bound in-memory session. Never invent cookies, never log them.
const existing = sessions.sessionFor(instanceId);
if (
existing &&
existing.origin === origin &&
/^simadmin_session=[^;\s,]+$/u.test(existing.cookie)
)
return existing.cookie;
// Password-protected instances need a session for reboot/restart. Best-effort Keychain login
// keeps the control plane from silently dispatching unauthenticated upstream mutations.
try {
const result = await login.login(instanceId);
if (!result.authenticated) return undefined;
} catch {
return undefined;
}
const refreshed = sessions.sessionFor(instanceId);
return refreshed &&
refreshed.origin === origin &&
/^simadmin_session=[^;\s,]+$/u.test(refreshed.cookie)
? refreshed.cookie
: undefined;
};
const secureExecution = new SecureOperationExecution({
db: options.db,
registry: secureOperationRegistry,
transport: {
request: async ({ origin, path, body, contentType }) => {
request: async ({ origin, path, body, contentType, instanceId }) => {
const cookie = await resolveOperationCookie(instanceId, origin);
if (path === '/api/network/register-auto') {
const response = await options.upstream.postNetworkRegisterAuto(origin);
const response = await options.upstream.postNetworkRegisterAuto(origin, cookie);
return { status: response.status };
}
if (path === '/api/service/restart') {
const response = await options.upstream.postServiceRestart(origin);
const response = await options.upstream.postServiceRestart(origin, cookie);
return { status: response.status };
}
if (path === '/api/system/reboot') {
if (contentType !== 'application/json' || body !== JSON.stringify({ delay_seconds: 3 }))
throw new Error('UPSTREAM_REQUEST_INVALID');
const response = await options.upstream.postSystemReboot(origin, 3);
const response = await options.upstream.postSystemReboot(origin, 3, cookie);
return { status: response.status };
}
throw new Error('UPSTREAM_REQUEST_INVALID');
@@ -110,6 +176,21 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
...(options.now ? { now: options.now } : {}),
});
secureExecution.reconcileInterruptedJobs();
const scheduledDispatcher = new ScheduledOperationDispatcher({
db: options.db,
operations: secureExecution,
messages,
store: options.store,
repository: scheduledTasks,
...(options.now ? { now: options.now } : {}),
});
const scheduler = new SchedulerCoordinator({
db: options.db,
repository: scheduledTasks,
dispatch: (task, targets, _run, context) =>
scheduledDispatcher.dispatch(task, targets, context),
...(options.now ? { now: options.now } : {}),
});
const auth = new ConsoleAuthService({
db: options.db,
...(options.now ? { now: options.now } : {}),
@@ -130,6 +211,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
registerJobRoutes(app, { jobs });
registerAuditRoutes(app, { audit });
registerAutomationRoutes(app, {
service: automation,
repository: scheduledTasks,
runNow: (taskId, actor, requestId) => scheduler.runNow(taskId, actor, requestId),
});
registerEventRoutes(app, {
journal: eventJournal,
...(options.authenticateEventStream
@@ -141,5 +227,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
Object.assign(app, {
retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(),
});
app.addHook('onClose', async () => scheduler.stop());
scheduler.start();
return app as ControlPlaneApp;
}
+2
View File
@@ -66,4 +66,6 @@ export type {
InstanceServiceOptions,
} from './application/instances/instance-service.js';
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
export { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js';
export { ScheduledTaskService } from './application/automation/scheduled-task-service.js';
export * as databaseSchema from './infrastructure/database/schema.js';
@@ -71,6 +71,8 @@ describe('database migrations', () => {
'job_items',
'jobs',
'operation_preparations',
'scheduled_runs',
'scheduled_tasks',
'schema_migrations',
'secret_cleanup_tasks',
'secret_references',
@@ -304,6 +304,58 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)',
],
},
{
id: 8,
name: 'scheduled-automation',
statements: [
`CREATE TABLE scheduled_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
operation_type TEXT NOT NULL CHECK (operation_type IN ('restart-service','reboot-system','send-sms')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
cron_expression TEXT NOT NULL,
timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'),
target_selector_json TEXT NOT NULL,
sms_secret_reference TEXT,
sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0),
effective_start_at TEXT,
effective_end_at TEXT,
misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')),
overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')),
retry_policy_json TEXT NOT NULL,
next_due_at TEXT,
last_evaluated_at TEXT,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)),
CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL))
)`,
`CREATE TABLE scheduled_runs (
id TEXT PRIMARY KEY,
scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT,
schedule_version INTEGER NOT NULL CHECK (schedule_version > 0),
task_snapshot_json TEXT NOT NULL,
due_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
target_snapshot_json TEXT NOT NULL,
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')),
reason TEXT,
job_ids_json TEXT NOT NULL DEFAULT '[]',
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')),
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0),
UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source)
)`,
'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL',
'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)',
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -255,3 +255,67 @@ export const secretCleanupTasks = sqliteTable(
},
(table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)],
);
export const scheduledTasks = sqliteTable(
'scheduled_tasks',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
operationType: text('operation_type').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
version: integer('version').notNull().default(1),
cronExpression: text('cron_expression').notNull(),
timezone: text('timezone').notNull(),
targetSelectorJson: text('target_selector_json').notNull(),
smsSecretReference: text('sms_secret_reference'),
smsRecipientCount: integer('sms_recipient_count'),
effectiveStartAt: text('effective_start_at'),
effectiveEndAt: text('effective_end_at'),
misfirePolicy: text('misfire_policy').notNull(),
overlapPolicy: text('overlap_policy').notNull(),
retryPolicyJson: text('retry_policy_json').notNull(),
nextDueAt: text('next_due_at'),
lastEvaluatedAt: text('last_evaluated_at'),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull(),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
deletedAt: text('deleted_at'),
},
(table) => [
check('scheduled_tasks_enabled_check', sql`${table.enabled} in (0, 1)`),
check('scheduled_tasks_version_check', sql`${table.version} > 0`),
index('idx_scheduled_tasks_enabled_next_due').on(table.enabled, table.nextDueAt),
],
);
export const scheduledRuns = sqliteTable(
'scheduled_runs',
{
id: text('id').primaryKey(),
scheduledTaskId: text('scheduled_task_id')
.notNull()
.references(() => scheduledTasks.id, { onDelete: 'restrict' }),
scheduleVersion: integer('schedule_version').notNull(),
taskSnapshotJson: text('task_snapshot_json').notNull(),
dueAt: text('due_at').notNull(),
claimedAt: text('claimed_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
targetSnapshotJson: text('target_snapshot_json').notNull(),
outcome: text('outcome'),
reason: text('reason'),
jobIdsJson: text('job_ids_json').notNull().default('[]'),
triggerSource: text('trigger_source').notNull(),
attempt: integer('attempt').notNull().default(1),
},
(table) => [
unique('scheduled_runs_occurrence_unique').on(
table.scheduledTaskId,
table.scheduleVersion,
table.dueAt,
table.triggerSource,
),
index('idx_scheduled_runs_task_due').on(table.scheduledTaskId, desc(table.dueAt)),
],
);
@@ -151,7 +151,23 @@ describe('MacOSKeychainSecretStore', () => {
}
});
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(4097)])(
it('stores a maximum-sized scheduled SMS payload', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const payload = JSON.stringify({
recipients: Array.from(
{ length: 50 },
(_, index) => `1380013${String(index).padStart(4, '0')}`,
),
content: '字'.repeat(2_000),
});
await expect(
store.set({ instanceId: 'task-1', purpose: 'scheduled-sms', slot: 'rotation-1' }, payload),
).resolves.toMatch(/^keychain:/);
});
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(16_385)])(
'rejects non-line-safe or oversized secret input before invoking the runner',
async (value) => {
const runner = new FakeRunner();
@@ -181,7 +181,7 @@ export class MacOSKeychainSecretStore implements SecretStore {
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/.test(value) ||
Buffer.byteLength(value, 'utf8') > 4096
Buffer.byteLength(value, 'utf8') > 16_384
) {
throw new SecretStoreError('INVALID_SECRET', 'Secret is not valid for Keychain storage');
}
@@ -4,9 +4,13 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string): Promise<{ readonly status: number }>;
postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>;
postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postSystemReboot(
origin: string,
delaySeconds: number,
cookie?: string,
): Promise<{ readonly status: number }>;
}
export function createSafeControlPlaneUpstream(
transport: SafeUpstreamTransport,
@@ -15,8 +19,9 @@ export function createSafeControlPlaneUpstream(
return {
get: (url) => transport.get(url),
request: (request) => gateway.request(request),
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
postServiceRestart: (origin) => gateway.postServiceRestart(origin),
postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds),
postNetworkRegisterAuto: (origin, cookie) => gateway.postNetworkRegisterAuto(origin, cookie),
postServiceRestart: (origin, cookie) => gateway.postServiceRestart(origin, cookie),
postSystemReboot: (origin, delaySeconds, cookie) =>
gateway.postSystemReboot(origin, delaySeconds, cookie),
};
}
@@ -65,7 +65,7 @@ describe('SafeUpstreamGateway', () => {
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
url: 'http://example.com/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
@@ -74,13 +74,50 @@ describe('SafeUpstreamGateway', () => {
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
url: 'http://example.com/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
});
it('allows HTTP auth only for literal private instance hosts', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
},
},
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
});
expect(calls).toEqual([
{
url: 'http://192.168.1.20:8080/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
{
url: 'http://192.168.1.20:8080/api/auth/logout',
headers: { cookie: 'simadmin_session=opaque' },
body: '',
},
]);
});
it('allows audited resource GETs without a cookie for passwordless instances', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
@@ -121,6 +158,23 @@ describe('SafeUpstreamGateway', () => {
);
});
it('dispatches an SMS at the 2,000-character automation limit', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
transport: { get: vi.fn(), post },
});
const content = '字'.repeat(2_000);
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 },
});
expect(post).toHaveBeenCalledOnce();
});
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: '{}' }));
@@ -184,4 +238,35 @@ describe('SafeUpstreamGateway', () => {
},
]);
});
it('forwards only a canonical origin-bound simadmin_session cookie on restart ops', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 204, headers: {}, body: '' };
},
},
});
const cookie = 'simadmin_session=opaque-token';
await gateway.postServiceRestart('http://192.168.1.20:8080', cookie);
await gateway.postSystemReboot('http://192.168.1.20:8080', 3, cookie);
expect(calls).toEqual([
{
url: 'http://192.168.1.20:8080/api/service/restart',
headers: { cookie },
body: '',
},
{
url: 'http://192.168.1.20:8080/api/system/reboot',
headers: { 'content-type': 'application/json', cookie },
body: '{"delay_seconds":3}',
},
]);
await expect(
gateway.postServiceRestart('http://192.168.1.20:8080', 'simadmin_session=bad; extra'),
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
});
});
@@ -14,6 +14,25 @@ export interface SafeUpstreamTransport {
body: string,
): Promise<TransportResponse>;
}
/** Literal private hosts only — hostnames that still need DNS stay HTTPS for auth secrets. */
const isLiteralPrivateHost = (hostname: string): boolean => {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host.includes(':')) return host.startsWith('fc') || host.startsWith('fd');
const parts = host.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
return false;
const [a, b, c] = parts;
if (a === undefined || b === undefined || c === undefined) return false;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 192 && b === 0 && c === 0)
);
};
export class SafeUpstreamGateway {
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
private assertOrigin(origin: string): string {
@@ -35,11 +54,24 @@ export class SafeUpstreamGateway {
return parsed.origin;
}
private async postZeroBody(origin: string, path: string): Promise<UpstreamResponse> {
private sessionHeaders(
cookie: string | undefined,
extra: Readonly<Record<string, string>> = {},
): Readonly<Record<string, string>> {
if (cookie === undefined) return extra;
if (!/^simadmin_session=[^;\s,]+$/u.test(cookie)) throw this.notDispatched();
return { ...extra, cookie };
}
private async postZeroBody(
origin: string,
path: string,
cookie?: string,
): Promise<UpstreamResponse> {
const base = this.assertOrigin(origin);
const url = `${base}${path}`;
try {
return await this.options.transport.post(url, {}, '');
return await this.options.transport.post(url, this.sessionHeaders(cookie), '');
} catch (error) {
if (
error instanceof UpstreamError &&
@@ -50,22 +82,26 @@ export class SafeUpstreamGateway {
}
}
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/network/register-auto');
async postNetworkRegisterAuto(origin: string, cookie?: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/network/register-auto', cookie);
}
async postServiceRestart(origin: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/service/restart');
async postServiceRestart(origin: string, cookie?: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/service/restart', cookie);
}
async postSystemReboot(origin: string, delaySeconds: number): Promise<UpstreamResponse> {
async postSystemReboot(
origin: string,
delaySeconds: number,
cookie?: string,
): Promise<UpstreamResponse> {
if (delaySeconds !== 3) throw this.notDispatched();
const base = this.assertOrigin(origin);
const url = `${base}/api/system/reboot`;
try {
return await this.options.transport.post(
url,
{ 'content-type': 'application/json' },
this.sessionHeaders(cookie, { 'content-type': 'application/json' }),
JSON.stringify({ delay_seconds: 3 }),
);
} catch (error) {
@@ -92,7 +128,10 @@ export class SafeUpstreamGateway {
request.body !== undefined ||
request.sms !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(url.pathname !== '/api/stats' &&
url.pathname !== '/api/sim' &&
url.pathname !== '/api/health' &&
!smsQuery) ||
(!smsList && url.search) ||
url.hash ||
url.username ||
@@ -114,8 +153,8 @@ export class SafeUpstreamGateway {
(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 ||
sms.content.length > 2000 ||
Buffer.byteLength(sms.content, 'utf8') > 8000 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
@@ -129,7 +168,9 @@ export class SafeUpstreamGateway {
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
// Auth secrets may travel over HTTPS anywhere, or over HTTP only to literal private hosts.
// Public/cleartext auth is still rejected here; SSRF private-only dial remains in transport.
if (!this.allowsAuthProtocol(url)) throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
if (typeof request.secret !== 'string' || request.body !== '[REDACTED]')
@@ -147,6 +188,12 @@ export class SafeUpstreamGateway {
}
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
}
private allowsAuthProtocol(url: URL): boolean {
if (url.protocol === 'https:') return true;
if (url.protocol !== 'http:') return false;
return isLiteralPrivateHost(url.hostname);
}
private notDispatched(): OperationNotDispatchedError {
return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
}
@@ -0,0 +1,185 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { buildApp } from '../../app.js';
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerAutomationRoutes } from './automation-routes.js';
class MemorySecrets implements SecretStore {
readonly values = new Map<string, string>();
async set(key: SecretKey, value: string): Promise<string> {
const reference = `memory://${key.instanceId}/${key.purpose}`;
this.values.set(reference, value);
return reference;
}
async get(reference: string) {
return this.values.get(reference);
}
async delete(reference: string) {
return this.values.delete(reference);
}
}
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const repository = new ScheduledTaskRepository(db);
let id = 0;
const service = new ScheduledTaskService({
repository,
store: new MemorySecrets(),
now: () => new Date('2026-07-30T00:00:00.000Z'),
id: () => `task-${++id}`,
});
const app = buildApp({
registerRoutes: (scope) => registerAutomationRoutes(scope, { service, repository }),
});
return { app, db };
}
const restart = {
name: 'Morning restart',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
targetSelector: { mode: 'fixed', instanceIds: ['instance-a'] },
};
describe('automation HTTP routes', () => {
it('previews Beijing-time Cron and creates a redacted schedule', async () => {
const { app, db } = fixture();
const preview = await app.inject({
method: 'POST',
url: '/api/v1/automation/cron/preview',
payload: { cronExpression: '0 9 * * *', count: 2 },
});
expect(preview.statusCode).toBe(200);
expect(preview.json()).toEqual({
timezone: 'Asia/Shanghai',
occurrences: ['2026-07-30T01:00:00.000Z', '2026-07-31T01:00:00.000Z'],
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: restart,
});
expect(created.statusCode).toBe(201);
expect(created.headers.etag).toBe('"version-1"');
expect(created.json()).toMatchObject({ id: 'task-1', timezone: 'Asia/Shanghai' });
expect(created.body).not.toMatch(/confirmationToken|smsSecretReference/i);
await app.close();
db.close();
});
it('requires optimistic version headers for state changes and preserves run history on delete', async () => {
const { app, db } = fixture();
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
const missing = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1/state',
payload: { enabled: false },
});
expect(missing.statusCode).toBe(428);
const paused = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1/state',
headers: { 'if-match': '"version-1"' },
payload: { enabled: false },
});
expect(paused.statusCode).toBe(200);
expect(paused.json()).toMatchObject({ enabled: false, version: 2 });
const removed = await app.inject({
method: 'DELETE',
url: '/api/v1/automation/schedules/task-1',
headers: { 'if-match': '"version-2"' },
});
expect(removed.statusCode).toBe(204);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/automation/schedules' })).json(),
).toMatchObject({ items: [] });
await app.close();
db.close();
});
it('rejects alternate timezone and unknown preview keys with sanitized problems', async () => {
const { app, db } = fixture();
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: { ...restart, timezone: 'UTC' },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
expect(invalid.body).not.toContain('stack');
await app.close();
db.close();
});
it('updates and duplicates schedules with optimistic version checks', async () => {
const { app, db } = fixture();
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
const missingVersion = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1',
payload: { name: 'Updated restart' },
});
expect(missingVersion.statusCode).toBe(428);
const updated = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1',
headers: { 'if-match': '"version-1"' },
payload: { name: 'Updated restart', effectiveEndAt: '2026-08-30T00:00:00.000Z' },
});
expect(updated.statusCode).toBe(200);
expect(updated.headers.etag).toBe('"version-2"');
expect(updated.json()).toMatchObject({ name: 'Updated restart', version: 2 });
const duplicated = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules/task-1/duplicate',
headers: { 'if-match': '"version-2"' },
});
expect(duplicated.statusCode).toBe(201);
expect(duplicated.json()).toMatchObject({
id: 'task-2',
name: 'Updated restart copy',
enabled: false,
});
await app.close();
db.close();
});
it('bounds and validates schedule pagination', async () => {
const { app, db } = fixture();
for (const name of ['One', 'Two', 'Three'])
await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: { ...restart, name },
});
const page = await app.inject({
method: 'GET',
url: '/api/v1/automation/schedules?page=2&pageSize=1',
});
expect(page.statusCode).toBe(200);
expect(page.json()).toMatchObject({
items: [{ id: 'task-2' }],
page: { page: 2, pageSize: 1, total: 3 },
});
const invalid = await app.inject({
method: 'GET',
url: '/api/v1/automation/schedules?pageSize=101',
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
await app.close();
db.close();
});
});
@@ -0,0 +1,239 @@
import { AUTOMATION_TIMEZONE } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
export interface AutomationRoutesOptions {
readonly service: ScheduledTaskService;
readonly repository: ScheduledTaskRepository;
readonly runNow?: (taskId: string, actor: string, requestId: string) => Promise<unknown>;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
) {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
}
function version(request: FastifyRequest, reply: FastifyReply): number | undefined {
const value = request.headers['if-match'];
const match = typeof value === 'string' ? /^"version-([1-9]\d*)"$/.exec(value) : null;
if (!match) {
problem(request, reply, 428, 'VERSION_REQUIRED', 'A current schedule version is required.');
return undefined;
}
return Number(match[1]);
}
function object(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError('Request body must be an object');
return value as Record<string, unknown>;
}
function pagination(
value: unknown,
extraKeys: readonly string[] = [],
): { readonly page: number; readonly pageSize: number; readonly query: Record<string, unknown> } {
const query = object(value);
const allowed = new Set(['page', 'pageSize', ...extraKeys]);
if (Object.keys(query).some((key) => !allowed.has(key)))
throw new TypeError('Unknown automation list query');
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw))
throw new TypeError(`${key} is invalid`);
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new TypeError(`${key} is invalid`);
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const page = integer('page', 1, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1);
return { page, pageSize, query };
}
function pageItems<T>(items: readonly T[], page: number, pageSize: number) {
const offset = (page - 1) * pageSize;
return {
items: items.slice(offset, offset + pageSize),
page: { page, pageSize, total: items.length },
};
}
async function action(
request: FastifyRequest,
reply: FastifyReply,
operation: () => unknown | Promise<unknown>,
) {
try {
return await operation();
} catch (error) {
if (error instanceof TypeError)
return problem(
request,
reply,
400,
'AUTOMATION_VALIDATION_FAILED',
'The automation request is invalid.',
);
if (error instanceof Error && /not found|version changed/i.test(error.message))
return problem(
request,
reply,
409,
'SCHEDULE_VERSION_CONFLICT',
'The schedule changed or no longer exists.',
);
throw error;
}
}
const etag = (version: number) => `"version-${version}"`;
export function registerAutomationRoutes(
app: FastifyInstance,
options: AutomationRoutesOptions,
): void {
app.post('/api/v1/automation/cron/preview', async (request, reply) =>
action(request, reply, () => {
const body = object(request.body);
if (Object.keys(body).some((key) => key !== 'cronExpression' && key !== 'count'))
throw new TypeError('Unknown preview field');
if (typeof body.cronExpression !== 'string')
throw new TypeError('cronExpression is required');
const count = body.count === undefined ? 5 : body.count;
if (!Number.isSafeInteger(count)) throw new TypeError('count is invalid');
return {
timezone: AUTOMATION_TIMEZONE,
occurrences: options.service.preview(body.cronExpression, count as number),
};
}),
);
app.get('/api/v1/automation/schedules', async (request, reply) =>
action(request, reply, () => {
const { page, pageSize } = pagination(request.query);
return pageItems(options.service.list(), page, pageSize);
}),
);
app.get('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const task = options.service.get((request.params as { taskId: string }).taskId);
if (!task)
return problem(request, reply, 404, 'SCHEDULE_NOT_FOUND', 'The schedule does not exist.');
return reply.header('ETag', etag(task.version)).send(task);
});
app.post('/api/v1/automation/schedules', async (request, reply) =>
action(request, reply, async () => {
const task = await options.service.create('console-operator', request.body);
return reply.code(201).header('ETag', etag(task.version)).send(task);
}),
);
app.patch('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
const task = await options.service.update(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
request.body,
);
return reply.header('ETag', etag(task.version)).send(task);
});
});
app.post('/api/v1/automation/schedules/:taskId/duplicate', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
const task = await options.service.duplicate(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
);
return reply.code(201).header('ETag', etag(task.version)).send(task);
});
});
app.patch('/api/v1/automation/schedules/:taskId/state', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, () => {
const body = object(request.body);
if (Object.keys(body).length !== 1 || typeof body.enabled !== 'boolean')
throw new TypeError('enabled is required');
const task = options.service.setEnabled(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
body.enabled,
);
return reply.header('ETag', etag(task.version)).send(task);
});
});
app.delete('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
await options.service.remove(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
);
return reply.code(204).send();
});
});
app.get('/api/v1/automation/runs', async (request, reply) =>
action(request, reply, () => {
const { page, pageSize, query } = pagination(request.query, ['scheduledTaskId']);
const scheduledTaskId =
typeof query.scheduledTaskId === 'string' && query.scheduledTaskId
? query.scheduledTaskId
: undefined;
if (query.scheduledTaskId !== undefined && !scheduledTaskId)
throw new TypeError('scheduledTaskId is invalid');
return pageItems(options.repository.listRuns(scheduledTaskId), page, pageSize);
}),
);
app.post('/api/v1/automation/schedules/:taskId/run', async (request, reply) => {
const runNow = options.runNow;
if (!runNow)
return problem(
request,
reply,
409,
'SCHEDULER_UNAVAILABLE',
'The scheduler is not available.',
);
return action(request, reply, async () => {
const run = await runNow(
(request.params as { taskId: string }).taskId,
'console-operator',
request.id,
);
return reply.code(202).send(run);
});
});
}
@@ -400,7 +400,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
required: ['phoneNumber', 'content'],
properties: {
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 1600 },
content: { type: 'string', minLength: 1, maxLength: 2000 },
},
},
},
@@ -31,8 +31,8 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
keychainMetadataCheck: async () => true,
};
+6 -1
View File
@@ -4,7 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>多实例 SimAdmin 管理台</title>
<meta name="theme-color" content="#142137" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23315bea%22/><path d=%22M18 42h6V31h-6zm11 0h6V22h-6zm11 0h6V13h-6z%22 fill=%22white%22/></svg>"
/>
<title>SimAdmin Nexus · 多节点控制台</title>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -13,6 +13,8 @@
},
"dependencies": {
"@multi-simadmin/contracts": "workspace:*",
"animal-island-ui": "1.3.0",
"classnames": "2.5.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+87 -20
View File
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppShell, type InstanceContext } from './app-shell.js';
import type { AuditDataSource } from './audit/audit-page.js';
import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js';
import type { EventStreamClient } from './events/event-stream-client.js';
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js';
@@ -70,6 +71,20 @@ const quietEventStreamClient: EventStreamClient = {
const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
function emptyScheduleDataSource(): ScheduleDataSource {
return {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn(),
updateSchedule: vi.fn(),
duplicateSchedule: vi.fn(),
setEnabled: vi.fn(),
removeSchedule: vi.fn(),
previewCron: vi.fn().mockResolvedValue([]),
runNow: vi.fn(),
listRuns: vi.fn().mockResolvedValue([]),
};
}
describe('React AppShell and Fleet vertical slice', () => {
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -84,7 +99,9 @@ describe('React AppShell and Fleet vertical slice', () => {
const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ });
expect(
within(alpha).getByRole('link', { name: /打开 Alpha 实例仪表盘/ }).getAttribute('href'),
within(alpha)
.getByRole('link', { name: /打开 Alpha 实例仪表盘/ })
.getAttribute('href'),
).toBe('/instances/alpha/overview');
expect(
within(screen.getByRole('article', { name: /Bravo 实例概览/ })).getByText(
@@ -101,6 +118,7 @@ describe('React AppShell and Fleet vertical slice', () => {
});
it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => {
const user = userEvent.setup();
const fleetMessagesDataSource: FleetMessagesDataSource = {
load: vi.fn(async (instanceId) =>
instanceId === 'bravo'
@@ -131,11 +149,11 @@ describe('React AppShell and Fleet vertical slice', () => {
);
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
expect(within(card).queryByText('延迟')).toBeNull();
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).getByText('SimAdmin 2.0')).toBeTruthy();
expect(within(card).queryByText('可能过期')).toBeNull();
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
expect(within(card).getByText('18.4%')).toBeTruthy();
expect(within(card).getByText('63.2%')).toBeTruthy();
expect(within(card).getByText('46.7 °C')).toBeTruthy();
@@ -152,9 +170,14 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
expect(within(card).queryByText('bravo')).toBeNull();
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy();
expect(within(card).queryByRole('group', { name: '实例运维操作' })).toBeNull();
expect(within(card).queryByRole('button', { name: '重启服务 Bravo' })).toBeNull();
expect(within(card).queryByRole('button', { name: '系统重启 Bravo' })).toBeNull();
await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' }));
expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '系统重启 Bravo' })).toBeTruthy();
});
it('keeps message read failures separate from instance reachability and reports each once', async () => {
@@ -171,34 +194,64 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(bravo).getByText('需要认证')).toBeTruthy();
});
it('keeps Jobs and Audit routes compatible without left nav, settings in top bar only', async () => {
it('keeps every global workspace reachable and exposes settings subsections', async () => {
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
const scheduleDataSource: ScheduleDataSource = {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn(),
updateSchedule: vi.fn(),
duplicateSchedule: vi.fn(),
setEnabled: vi.fn(),
removeSchedule: vi.fn(),
previewCron: vi.fn().mockResolvedValue([]),
runNow: vi.fn(),
listRuns: vi.fn().mockResolvedValue([]),
};
const { rerender } = render(
<AppShell
pathname="/jobs"
jobsDataSource={jobsDataSource}
scheduleDataSource={scheduleDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
expect(screen.queryByRole('navigation', { name: '全局导航' })).toBeNull();
expect(screen.queryByRole('link', { name: '任务' })).toBeNull();
expect(screen.queryByRole('link', { name: '审计' })).toBeNull();
expect(screen.queryByRole('link', { name: '实例总览' })).toBeNull();
expect(screen.getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
'/settings/system',
const navigation = screen.getByRole('navigation', { name: '全局导航' });
expect(within(navigation).getByRole('link', { name: '节点' }).getAttribute('href')).toBe(
'/fleet',
);
expect(within(navigation).getByRole('link', { name: '自动化' }).getAttribute('href')).toBe(
'/automation',
);
expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe(
'/settings/instances',
);
expect(
within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'),
).toBe('page');
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
expect(within(navigation).getAllByRole('link')).toHaveLength(3);
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
rerender(
<AppShell
pathname="/audit"
auditDataSource={auditDataSource}
scheduleDataSource={scheduleDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
const settingsNavigation = screen.getByRole('navigation', { name: '设置导航' });
expect(within(settingsNavigation).getByRole('link', { name: '实例管理' })).toBeTruthy();
expect(
within(settingsNavigation)
.getByRole('link', { name: '系统与安全' })
.getAttribute('aria-current'),
).toBe('page');
});
it('hides placeholder dev version badge in the top bar', () => {
@@ -229,7 +282,9 @@ describe('React AppShell and Fleet vertical slice', () => {
<AppShell pathname="/instances/bravo/overview" instanceDataSource={instanceDataSource} />,
);
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
'正在加载实例',
);
expect(await screen.findByText('Bravo')).toBeTruthy();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
@@ -283,6 +338,7 @@ describe('React AppShell and Fleet vertical slice', () => {
await user.click(screen.getByRole('button', { name: /需认证/ }));
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '批量选择' }));
await user.click(screen.getByRole('button', { name: '全选本页' }));
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
true,
@@ -333,11 +389,12 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull();
expect(screen.queryByRole('table')).toBeNull();
await user.click(screen.getByRole('button', { name: '批量选择' }));
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
false,
);
await user.click(screen.getByRole('button', { name: '批量操作' }));
expect(
(screen.getByRole('button', { name: '批量重启服务' }) as HTMLButtonElement).disabled,
).toBe(false);
await user.click(screen.getByRole('button', { name: '批量重启服务' }));
expect(screen.getByText('已选择 1 项。重启将逐个实例安全确认后执行。')).toBeTruthy();
expect(screen.getByRole('button', { name: '批量重启服务' })).toBeTruthy();
expect(screen.getByRole('button', { name: '批量系统重启' })).toBeTruthy();
@@ -357,6 +414,7 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(screen.queryByRole('article', { name: /Instance 11 实例概览/ })).toBeNull();
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
fireEvent.click(screen.getByRole('button', { name: '全选本页' }));
expect(screen.getByText('已选择 10 项')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
@@ -383,7 +441,9 @@ describe('React AppShell and Fleet vertical slice', () => {
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
expect(screen.queryByText('Owner modem')).toBeNull();
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
'正在加载实例',
);
});
it.each([
@@ -408,7 +468,13 @@ describe('React AppShell and Fleet vertical slice', () => {
);
vi.stubGlobal('fetch', fetcher);
render(<AppShell pathname={pathname} eventStreamClient={quietEventStreamClient} />);
render(
<AppShell
pathname={pathname}
eventStreamClient={quietEventStreamClient}
scheduleDataSource={emptyScheduleDataSource()}
/>,
);
expect(await screen.findByText(emptyMessage)).toBeTruthy();
expect(fetcher).toHaveBeenCalledTimes(1);
@@ -446,6 +512,7 @@ describe('React AppShell and Fleet vertical slice', () => {
<AppShell
pathname={pathname}
eventStreamClient={quietEventStreamClient}
scheduleDataSource={emptyScheduleDataSource()}
{...(sourceProp === 'jobsDataSource'
? { jobsDataSource: injectedSource as JobsDataSource }
: { auditDataSource: injectedSource as AuditDataSource })}
+131 -30
View File
@@ -3,6 +3,13 @@ import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Tag } from 'animal-island-ui';
import { Icon } from './ui/icon.js';
import {
AutomationPage,
type AutomationDataSource as ScheduleDataSource,
} from './automation/automation-page.js';
import { createAutomationApiDataSource } from './automation/automation-api-data-source.js';
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
@@ -41,7 +48,7 @@ import {
type InstanceCapabilityMap,
} from './instances/instance-detail.js';
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
export type GlobalSection = 'fleet' | 'automation' | 'settings';
export type InstanceModule =
| 'overview'
| 'cellular'
@@ -55,6 +62,7 @@ export type InstanceModule =
export type RouteKind =
| 'redirect'
| 'fleet'
| 'automation'
| 'instance-new'
| `instance-${InstanceModule}`
| 'jobs'
@@ -109,6 +117,7 @@ export interface AppShellProps {
otaDataSource?: OtaDataSource;
jobsDataSource?: JobsDataSource;
auditDataSource?: AuditDataSource;
scheduleDataSource?: ScheduleDataSource;
eventStreamClient?: EventStreamClient;
}
@@ -132,6 +141,7 @@ export function resolveRoute(input: string): ResolvedRoute {
if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' };
const staticRoutes: Readonly<Record<string, RouteKind>> = {
'/fleet': 'fleet',
'/automation': 'automation',
'/instances/new': 'instance-new',
'/jobs': 'jobs',
'/audit': 'audit',
@@ -162,8 +172,8 @@ export function resolveRoute(input: string): ResolvedRoute {
function section(route: ResolvedRoute): GlobalSection | undefined {
if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-'))
return 'fleet';
if (route.kind.startsWith('job')) return 'jobs';
if (route.kind.startsWith('audit')) return 'audit';
if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit'))
return 'automation';
if (route.kind.startsWith('settings')) return 'settings';
return undefined;
}
@@ -188,6 +198,7 @@ function Page({
otaDataSource,
jobsDataSource,
auditDataSource,
scheduleDataSource,
fleetRefreshSignal,
detailRefreshSignal,
}: {
@@ -210,6 +221,7 @@ function Page({
otaDataSource: OtaDataSource | undefined;
jobsDataSource: JobsDataSource | undefined;
auditDataSource: AuditDataSource | undefined;
scheduleDataSource: ScheduleDataSource;
fleetRefreshSignal: number;
detailRefreshSignal: number;
}): ReactNode {
@@ -237,18 +249,25 @@ function Page({
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
/>
);
if (route.kind === 'jobs')
if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit')
return (
<JobsPage
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'audit')
return (
<AuditPage
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
refreshSignal={fleetRefreshSignal}
<AutomationPage
dataSource={scheduleDataSource}
initialTab={
route.kind === 'jobs' ? 'runs' : route.kind === 'audit' ? 'records' : 'schedules'
}
runsContent={
<JobsPage
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
}
recordsContent={
<AuditPage
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
}
/>
);
if (route.kind === 'settings-instances')
@@ -405,6 +424,33 @@ function displayConsoleVersion(version: string | undefined): string | undefined
return trimmed;
}
const GLOBAL_NAVIGATION: readonly {
section: GlobalSection;
href: string;
label: string;
icon: 'grid' | 'jobs' | 'settings';
}[] = [
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
{ section: 'settings', href: '/settings/instances', label: '设置', icon: 'settings' },
];
const STREAM_LABELS = {
connecting: '正在连接',
open: '实时连接正常',
reconnecting: '正在重新连接',
resetting: '正在同步状态',
closed: '实时连接已关闭',
} as const;
const STREAM_COLORS = {
connecting: 'app-yellow',
open: 'app-teal',
reconnecting: 'app-orange',
resetting: 'app-yellow',
closed: 'app-red',
} as const;
export function AppShell({
pathname,
version = 'dev',
@@ -426,6 +472,7 @@ export function AppShell({
otaDataSource,
jobsDataSource,
auditDataSource,
scheduleDataSource,
eventStreamClient,
}: AppShellProps) {
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
@@ -441,6 +488,10 @@ export function AppShell({
() => auditDataSource ?? createAuditApiDataSource(),
[auditDataSource],
);
const resolvedScheduleDataSource = useMemo(
() => scheduleDataSource ?? createAutomationApiDataSource(),
[scheduleDataSource],
);
const resolved = resolveRoute(pathname);
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
const routeInstanceId = route.params?.instanceId;
@@ -598,29 +649,71 @@ export function AppShell({
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet">
SimAdmin
</a>
<div className="topbar-actions">
<span className="connection-status"></span>
{consoleVersion ? (
<span className="version-badge" aria-label={`控制台版本 ${consoleVersion}`}>
v{consoleVersion}
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
<span className="product-mark" aria-hidden="true">
<span className="product-signal">
<i />
<i />
<i />
</span>
) : null}
<a
className="topbar-settings"
href="/settings/system"
aria-current={currentSection === 'settings' ? 'page' : undefined}
</span>
<span className="product-copy">
<strong>SimAdmin Control</strong>
<small></small>
</span>
</a>
<nav className="global-navigation" aria-label="全局导航">
{GLOBAL_NAVIGATION.map((item) => (
<a
key={item.section}
href={item.href}
aria-current={currentSection === item.section ? 'page' : undefined}
>
<Icon name={item.icon} />
<span>{item.label}</span>
</a>
))}
</nav>
<div className="topbar-actions">
<Tag
className="connection-status"
color={STREAM_COLORS[refresh.stream]}
variant="soft"
size="small"
>
</a>
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
{STREAM_LABELS[refresh.stream]}
</span>
</Tag>
{consoleVersion ? (
<Tag className="version-badge" color="brown" variant="soft" size="small">
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
</Tag>
) : null}
</div>
</header>
<div className="app-layout app-layout-single">
<main id="main-content" tabIndex={-1}>
{currentSection === 'settings' ? (
<nav className="settings-navigation" aria-label="设置导航">
<a
href="/settings/instances"
aria-current={route.kind === 'settings-system' ? undefined : 'page'}
>
</a>
<a
href="/settings/system"
aria-current={route.kind === 'settings-system' ? 'page' : undefined}
>
</a>
</nav>
) : null}
{routeInstanceId && instanceLoading ? (
<p role="status"></p>
<p role="status" aria-label="实例加载状态">
</p>
) : routeInstanceId && instanceLoadFailed ? (
<p role="alert" className="state-panel state-error">
@@ -646,12 +739,20 @@ export function AppShell({
otaDataSource={otaDataSource}
jobsDataSource={resolvedJobsDataSource}
auditDataSource={resolvedAuditDataSource}
scheduleDataSource={resolvedScheduleDataSource}
fleetRefreshSignal={refresh.fleet}
detailRefreshSignal={refresh.detail}
/>
)}
</main>
</div>
<footer className="app-footer" aria-label="项目与组件库信息">
<p>
SimAdmin · {' '}
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
CC BY-NC 4.0使
</p>
</footer>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
<svg t="1777874742854" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13571" width="200" height="200"><path d="M512 57.677643C226.27383 57.677643 57.677643 226.27383 57.677643 512s168.596187 454.322357 454.322357 454.322357 454.322357-168.596187 454.322357-454.322357S797.72617 57.677643 512 57.677643z" fill="#1296db" p-id="13572" data-spm-anchor-id="a313x.search_index.0.i24.43193a81ZcyisA" class="selected"></path><path d="M286.64635 708.389712c0 11.801733 4.702946 23.071057 12.955286 31.412132s19.610399 12.955286 31.412132 12.955286h361.949393c24.490815 0 44.367418-19.876603 44.367418-44.367418v-308.797227l-122.010399-128.310572h-284.306412c-11.801733 0-23.071057 4.702946-31.412132 12.955286s-12.955286 19.610399-12.955286 31.412132v392.740381z" fill="#ffffff" p-id="13573" data-spm-anchor-id="a313x.search_index.0.i23.43193a81ZcyisA" class=""></path><path d="M692.963161 766.067355h-361.949393c-15.439861 0-29.90364-6.033969-40.818024-16.859618s-16.859619-25.378163-16.859619-40.818025v-392.740381c0-15.439861 6.033969-29.90364 16.859619-40.818024 10.914385-10.82565 25.378163-16.859619 40.818024-16.859619h289.985442l129.641594 136.296707v314.121317c0 31.767071-25.821837 57.677643-57.677643 57.677643z m-361.949393-481.475216c-8.25234 0-16.061005 3.194454-21.917505 9.139688-5.945234 5.856499-9.139688 13.665165-9.139688 21.917504v392.740381c0 8.25234 3.194454 16.061005 9.139688 21.917505 5.856499 5.856499 13.665165 9.139688 21.917505 9.139688h361.949393c17.125823 0 31.057192-13.931369 31.057193-31.057193v-303.473137l-114.467938-120.324436h-278.538648z" fill="#FFFFFF" p-id="13574"></path><path d="M464.64843 699.338759l0.532409-89.444714-50.401387-16.14974-50.401386 16.14974v45.077296c0 11.801733 4.702946 23.071057 12.955286 31.412132s19.610399 12.955286 31.412132 12.955286h55.902946z m97.519584 0l0.266204-89.444714-48.626689-16.14974-48.62669 16.14974-0.532409 89.444714h97.519584z m48.804159-152.801386l-48.183015-14.641248-48.981629 14.641248-47.739342-14.641248v-77.99792h-57.322703c-11.801733 0-23.071057 4.702946-31.412132 12.955285-8.341075 8.341075-12.955286 19.610399-12.955286 31.412132v111.539688l50.401386-16.14974 50.401387 16.14974 48.62669-16.14974 48.626689 16.14974 48.62669-16.14974 48.62669 16.14974v-77.99792l-48.715425 14.729983z m48.62669-48.271751c0-11.801733-4.702946-23.071057-12.955286-31.412132-8.341075-8.341075-19.610399-12.955286-31.412132-12.955285h-52.708492l0.35494 77.99792 48.183015 14.641248 48.62669-14.641248-0.088735-33.630503z m-97.430849 201.073137h53.152166c24.490815 0 44.367418-19.876603 44.367418-44.367418v-45.077296l-48.62669-16.14974-48.62669 16.14974-0.266204 89.444714z m0.354939-245.440554h-96.454766v77.99792l47.739342 14.641248 49.070364-14.641248-0.35494-77.99792z" fill="#E1B460" p-id="13575"></path><path d="M557.731272 703.775501h-148.985788c-13.044021 0-25.289428-5.057886-34.517851-14.286309s-14.286308-21.562565-14.286309-34.517851v-156.705719c0-13.044021 5.057886-25.289428 14.286309-34.517851 9.228423-9.228423 21.47383-14.286308 34.517851-14.286308h206.574696c13.044021 0 25.289428 5.057886 34.517851 14.286308s14.286308 21.47383 14.286309 34.517851v156.616985c0 26.886655-21.917504 48.804159-48.80416 48.804159l-57.588908 0.088735z m-88.557366-8.873484h88.557366l0.266205-81.813518-44.189948-14.729983-44.189948 14.641248-0.443675 81.902253z m-100.359098-81.813518v41.882842c0 10.64818 4.170537 20.675217 11.712998 28.217678s17.569497 11.712998 28.217678 11.712998h51.554939l0.443674-81.813518-45.964645-14.729983-45.964644 14.729983z m198.056152 0l-0.266204 81.813518h48.626689c22.006239 0 39.930676-17.924437 39.930676-39.930676v-41.882842l-44.189948-14.641248-44.101213 14.641248z m-152.091508-24.04714l50.401387 16.14974 48.62669-16.14974 48.626689 16.14974 48.62669-16.14974 44.189948 14.641248v-65.841248l-44.189948 13.310225-48.183015-14.641248-49.070364 14.641248-48.360486-14.818717h-0.177469v-0.088735l-3.549394-1.064818v-76.844368h-52.885962c-10.64818 0-20.675217 4.170537-28.217677 11.712999s-11.712998 17.569497-11.712999 28.217677v105.505719l45.87591-14.729982z m152.535182-60.428423l43.746274 13.310225 44.189948-13.310225v-30.258579c0-10.64818-4.170537-20.675217-11.712998-28.217678s-17.569497-11.712998-28.217678-11.712998h-48.27175l0.266204 70.189255z m-96.809705 0l43.3026 13.310225 44.633622-13.310225-0.266205-70.189255h-87.581282v70.189255z" fill="#666666" p-id="13576"></path></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'animal-island-ui';
import {
AUDIT_OUTCOMES,
@@ -287,9 +288,9 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
<h1 id="audit-title"></h1>
<p></p>
</div>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
<Button htmlType="button" size="small" onClick={() => setAttempt((value) => value + 1)}>
</button>
</Button>
</div>
<div className="fleet-toolbar">
{identifiers.map(([field, label]) => (
+57 -52
View File
@@ -1,4 +1,5 @@
import { type FormEvent, useEffect, useState } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import type { ConsoleAuthDataSource, ConsoleAuthStatus } from './console-auth.js';
import { createConsoleAuthApiDataSource } from './console-auth.js';
@@ -70,66 +71,70 @@ export function ConsoleAuthSettings({
<section className="settings-page" aria-labelledby="password-protection-title">
<header className="page-heading">
<p className="eyebrow">SYSTEM SETTINGS</p>
<h1 id="password-protection-title"></h1>
<h1 id="password-protection-title">
<Title color="app-green"></Title>
</h1>
<p></p>
<p> HTTP使 HTTPS</p>
<p> SimAdmin 访</p>
</header>
{error && !status ? <p role="alert">{error}</p> : null}
{status ? (
<form className="settings-card auth-settings" onSubmit={(event) => void save(event)}>
<label className="toggle-row">
<span>
<strong></strong>
<small>访</small>
</span>
<input
type="checkbox"
checked={enabled}
onChange={(event) => {
setEnabled(event.target.checked);
setNotice('');
}}
/>
</label>
{enabled && !status.configured ? (
<div className="auth-password-fields">
<label htmlFor="new-console-password">访</label>
<Card pattern="default" className="settings-card">
<form className="auth-settings" onSubmit={(event) => void save(event)}>
<label className="toggle-row">
<span>
<strong></strong>
<small>访</small>
</span>
<input
id="new-console-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
type="checkbox"
checked={enabled}
onChange={(event) => {
setEnabled(event.target.checked);
setNotice('');
}}
/>
<label htmlFor="confirm-console-password">访</label>
<input
id="confirm-console-password"
type="password"
autoComplete="new-password"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
/>
<small> 8 </small>
</div>
) : null}
{status.configured ? <p>访 API </p> : null}
{error ? <p role="alert">{error}</p> : null}
{notice ? <p role="status">{notice}</p> : null}
<button type="submit" disabled={saving}>
{saving ? '正在保存…' : '保存密码保护设置'}
</button>
{status.protectionEnabled && status.authenticated ? (
<button
type="button"
onClick={() => {
void source.logout().then(() => window.location.reload());
}}
>
退
</button>
) : null}
</form>
</label>
{enabled && !status.configured ? (
<div className="auth-password-fields">
<label htmlFor="new-console-password">访</label>
<input
id="new-console-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<label htmlFor="confirm-console-password">访</label>
<input
id="confirm-console-password"
type="password"
autoComplete="new-password"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
/>
<small> 8 </small>
</div>
) : null}
{status.configured ? <p>访 API </p> : null}
{error ? <p role="alert">{error}</p> : null}
{notice ? <p role="status">{notice}</p> : null}
<Button htmlType="submit" type="primary" disabled={saving} loading={saving}>
{saving ? '正在保存…' : '保存密码保护设置'}
</Button>
{status.protectionEnabled && status.authenticated ? (
<Button
htmlType="button"
onClick={() => {
void source.logout().then(() => window.location.reload());
}}
>
退
</Button>
) : null}
</form>
</Card>
) : !error ? (
<p role="status"></p>
) : null}
@@ -0,0 +1,95 @@
import type { CreateScheduledTaskRequest, ScheduledTask } from '@multi-simadmin/contracts';
import { describe, expect, it, vi } from 'vitest';
import { createAutomationApiDataSource } from './automation-api-data-source.js';
const input: CreateScheduledTaskRequest = {
name: 'Morning restart',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['alpha'] },
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 1, retryIntervalSeconds: 30 },
enabled: true,
};
const task: ScheduledTask = {
...input,
id: 'schedule/a',
version: 3,
createdBy: 'operator',
updatedBy: 'operator',
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
};
describe('Automation API data source', () => {
it('uses the authenticated schedule endpoints and optimistic version headers', async () => {
const fetcher = vi.fn(async (request: RequestInfo | URL, init?: RequestInit) => {
const url = String(request);
if (url.endsWith('/cron/preview')) {
return Response.json({ occurrences: ['2026-07-30T01:00:00.000Z'] });
}
if (url.endsWith('/runs')) return Response.json({ items: [] });
if (init?.method === 'DELETE') return new Response(null, { status: 204 });
if (url.endsWith('/run')) return Response.json({ runId: 'run-1' });
if (url.endsWith('/schedules') && !init?.method) return Response.json({ items: [task] });
return Response.json(task);
});
const source = createAutomationApiDataSource(fetcher as typeof fetch);
const controller = new AbortController();
await expect(source.listSchedules(controller.signal)).resolves.toEqual([task]);
await expect(source.createSchedule(input)).resolves.toEqual(task);
await expect(
source.updateSchedule(task.id, task.version, { name: 'Updated' }),
).resolves.toEqual(task);
await expect(source.duplicateSchedule(task.id, task.version)).resolves.toEqual(task);
await expect(source.setEnabled(task.id, task.version, false)).resolves.toEqual(task);
await expect(source.previewCron(input.cronExpression)).resolves.toEqual([
'2026-07-30T01:00:00.000Z',
]);
await expect(source.runNow(task.id)).resolves.toEqual({ runId: 'run-1' });
await expect(source.listRuns(controller.signal)).resolves.toEqual([]);
await expect(source.removeSchedule(task.id, task.version)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/state', {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': '"version-3"',
},
body: JSON.stringify({ enabled: false }),
});
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': '"version-3"',
},
body: JSON.stringify({ name: 'Updated' }),
});
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/duplicate', {
method: 'POST',
headers: { accept: 'application/json', 'if-match': '"version-3"' },
});
expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', {
method: 'DELETE',
headers: { 'if-match': '"version-3"' },
});
});
it('rejects non-success responses without exposing response bodies', async () => {
const fetcher = vi.fn(async () =>
Response.json({ detail: 'secret backend detail' }, { status: 500 }),
);
const source = createAutomationApiDataSource(fetcher as typeof fetch);
await expect(source.listSchedules()).rejects.toThrow('Automation request failed (500)');
await expect(source.listSchedules()).rejects.not.toThrow('secret backend detail');
});
});
@@ -0,0 +1,102 @@
import type {
CreateScheduledTaskRequest,
ScheduledRun,
ScheduledTask,
} from '@multi-simadmin/contracts';
import type { AutomationDataSource } from './automation-page.js';
async function json<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
return (await response.json()) as T;
}
export function createAutomationApiDataSource(fetcher: typeof fetch = fetch): AutomationDataSource {
return {
async listSchedules(signal) {
const page = await json<{ items: ScheduledTask[] }>(
await fetcher('/api/v1/automation/schedules', {
...(signal ? { signal } : {}),
headers: { accept: 'application/json' },
}),
);
return page.items;
},
async createSchedule(input) {
return json<ScheduledTask>(
await fetcher('/api/v1/automation/schedules', {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input satisfies CreateScheduledTaskRequest),
}),
);
},
async updateSchedule(id, version, input) {
return json<ScheduledTask>(
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': `"version-${version}"`,
},
body: JSON.stringify(input),
}),
);
},
async duplicateSchedule(id, version) {
return json<ScheduledTask>(
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/duplicate`, {
method: 'POST',
headers: { accept: 'application/json', 'if-match': `"version-${version}"` },
}),
);
},
async setEnabled(id, version, enabled) {
return json<ScheduledTask>(
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/state`, {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': `"version-${version}"`,
},
body: JSON.stringify({ enabled }),
}),
);
},
async removeSchedule(id, version) {
const response = await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'if-match': `"version-${version}"` },
});
if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
},
async previewCron(cronExpression) {
const preview = await json<{ occurrences: string[] }>(
await fetcher('/api/v1/automation/cron/preview', {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ cronExpression, count: 5 }),
}),
);
return preview.occurrences;
},
async runNow(id) {
return json<unknown>(
await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/run`, {
method: 'POST',
}),
);
},
async listRuns(signal) {
const page = await json<{ items: ScheduledRun[] }>(
await fetcher('/api/v1/automation/runs', {
...(signal ? { signal } : {}),
headers: { accept: 'application/json' },
}),
);
return page.items;
},
};
}
@@ -0,0 +1,182 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutomationPage, type AutomationDataSource } from './automation-page.js';
afterEach(cleanup);
function source(): AutomationDataSource {
return {
listSchedules: async () => [],
createSchedule: async (input) => ({
id: 'task-1',
version: 1,
createdBy: 'operator',
updatedBy: 'operator',
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
nextDueAt: '2026-07-30T01:00:00.000Z',
...input,
}),
updateSchedule: async () => undefined as never,
duplicateSchedule: async () => undefined as never,
setEnabled: async () => undefined,
removeSchedule: async () => undefined,
previewCron: async () => [
'2026-07-30T01:00:00.000Z',
'2026-07-31T01:00:00.000Z',
'2026-08-01T01:00:00.000Z',
'2026-08-02T01:00:00.000Z',
'2026-08-03T01:00:00.000Z',
],
runNow: async () => undefined,
listRuns: async () => [],
};
}
describe('AutomationPage', () => {
it('presents schedules, runs, and operation records as one Chinese workspace', async () => {
render(<AutomationPage dataSource={source()} />);
const tabs = screen.getByRole('tablist', { name: '自动化视图' });
expect(within(tabs).getByRole('tab', { name: '计划任务' }).getAttribute('aria-selected')).toBe(
'true',
);
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
expect(await screen.findByText('暂无计划任务')).not.toBeNull();
});
it('opens a progressive editor with dynamic tags and no timezone selector', async () => {
const user = userEvent.setup();
render(<AutomationPage dataSource={source()} />);
await user.click(screen.getByRole('button', { name: '创建任务' }));
const dialog = screen.getByRole('dialog', { name: '创建任务' });
expect(dialog).not.toBeNull();
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
expect(within(dialog).getByLabelText('标签')).not.toBeNull();
expect(within(dialog).queryByLabelText('时区')).toBeNull();
expect(within(dialog).getByText(/北京时间/)).not.toBeNull();
});
it('closes the editor with Escape and restores focus to its trigger', async () => {
const user = userEvent.setup();
render(<AutomationPage dataSource={source()} />);
const trigger = screen.getByRole('button', { name: '创建任务' });
await user.click(trigger);
expect(screen.getByRole('dialog', { name: '创建任务' })).not.toBeNull();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: '创建任务' })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('shows SMS fields and previews five-field Cron occurrences', async () => {
const user = userEvent.setup();
const dataSource = source();
render(<AutomationPage dataSource={dataSource} />);
await user.click(screen.getByRole('button', { name: '创建任务' }));
const dialog = screen.getByRole('dialog', { name: '创建任务' });
await user.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms');
expect(within(dialog).getByLabelText('收件号码')).not.toBeNull();
expect(within(dialog).getByLabelText('短信内容')).not.toBeNull();
await user.click(within(dialog).getByRole('button', { name: '预览后续执行' }));
expect(await within(dialog).findByText(/2026-07-30 09:00/)).not.toBeNull();
});
it('requires a final operation, target, and risk confirmation before creating', async () => {
const user = userEvent.setup();
const createSchedule = vi.fn(source().createSchedule);
render(<AutomationPage dataSource={{ ...source(), createSchedule }} />);
await user.click(screen.getByRole('button', { name: '创建任务' }));
const dialog = screen.getByRole('dialog', { name: '创建任务' });
await user.type(within(dialog).getByLabelText('任务名称'), 'Night restart');
await user.type(within(dialog).getByLabelText('实例 ID'), 'alpha, beta');
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
expect(createSchedule).not.toHaveBeenCalled();
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
expect(within(confirmation).getByText('重启 SimAdmin 服务')).not.toBeNull();
expect(within(confirmation).getByText('2 个固定实例')).not.toBeNull();
expect(within(confirmation).getByText('R2')).not.toBeNull();
await user.click(within(confirmation).getByRole('checkbox', { name: /我已核对/ }));
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
expect(createSchedule).toHaveBeenCalledOnce();
});
it('edits effective windows and duplicates an existing schedule', async () => {
const user = userEvent.setup();
const existing = {
id: 'task-1',
name: 'Morning restart',
operationType: 'restart-service' as const,
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai' as const,
targetSelector: { mode: 'fixed' as const, instanceIds: ['alpha', 'beta'] },
effectiveStartAt: '2026-08-01T01:00:00.000Z',
effectiveEndAt: '2026-09-01T01:00:00.000Z',
misfirePolicy: 'skip' as const,
overlapPolicy: 'skip' as const,
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
version: 4,
createdBy: 'operator',
updatedBy: 'operator',
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
};
const updateSchedule = vi.fn(async (_id, _version, input) => ({
...existing,
...input,
version: 5,
}));
const duplicateSchedule = vi.fn(async () => ({
...existing,
id: 'task-2',
name: 'Morning restart copy',
enabled: false,
version: 1,
}));
const dataSource: AutomationDataSource = {
...source(),
listSchedules: async () => [existing],
updateSchedule,
duplicateSchedule,
};
render(<AutomationPage dataSource={dataSource} />);
await screen.findByText('Morning restart');
expect(screen.queryByRole('menuitem', { name: '编辑 Morning restart' })).toBeNull();
await user.click(screen.getByRole('button', { name: '任务操作 Morning restart' }));
await user.click(screen.getByRole('menuitem', { name: '编辑 Morning restart' }));
const dialog = screen.getByRole('dialog', { name: '编辑任务' });
expect(within(dialog).getByLabelText('生效开始(可选)')).not.toBeNull();
expect(within(dialog).getByLabelText('生效结束(可选)')).not.toBeNull();
const name = within(dialog).getByLabelText('任务名称');
await user.clear(name);
await user.type(name, 'Updated restart');
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
await user.click(within(dialog).getByRole('checkbox', { name: /我已核对/ }));
await user.click(within(dialog).getByRole('button', { name: '确认并保存' }));
expect(updateSchedule).toHaveBeenCalledWith(
'task-1',
4,
expect.objectContaining({
name: 'Updated restart',
effectiveStartAt: '2026-08-01T01:00:00.000Z',
effectiveEndAt: '2026-09-01T01:00:00.000Z',
}),
);
await user.click(screen.getByRole('button', { name: '任务操作 Updated restart' }));
await user.click(screen.getByRole('menuitem', { name: '复制 Updated restart' }));
expect(duplicateSchedule).toHaveBeenCalledWith('task-1', 5);
expect(await screen.findByText('Morning restart copy')).not.toBeNull();
});
});
+880
View File
@@ -0,0 +1,880 @@
import type {
CreateScheduledTaskRequest,
ScheduledRun,
ScheduledTask,
UpdateScheduledTaskRequest,
} from '@multi-simadmin/contracts';
import {
useEffect,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { Icon } from '../ui/icon.js';
export type AutomationTab = 'schedules' | 'runs' | 'records';
export type ScheduleUpdateInput = Omit<UpdateScheduledTaskRequest, 'version'>;
export interface AutomationDataSource {
listSchedules(signal?: AbortSignal): Promise<readonly ScheduledTask[]>;
createSchedule(input: CreateScheduledTaskRequest): Promise<ScheduledTask>;
updateSchedule(id: string, version: number, input: ScheduleUpdateInput): Promise<ScheduledTask>;
duplicateSchedule(id: string, version: number): Promise<ScheduledTask>;
setEnabled(id: string, version: number, enabled: boolean): Promise<ScheduledTask | undefined>;
removeSchedule(id: string, version: number): Promise<void>;
previewCron(expression: string): Promise<readonly string[]>;
runNow(id: string): Promise<unknown>;
listRuns(signal?: AbortSignal): Promise<readonly ScheduledRun[]>;
}
export interface AutomationPageProps {
readonly dataSource: AutomationDataSource;
readonly initialTab?: AutomationTab;
readonly runsContent?: ReactNode;
readonly recordsContent?: ReactNode;
}
function beijingTime(value: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(value));
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
}
function beijingLocal(value?: string): string {
return value ? beijingTime(value).replace(' ', 'T') : '';
}
function beijingIso(value: string): string | undefined {
return value ? new Date(`${value}:00+08:00`).toISOString() : undefined;
}
function operationLabel(value: ScheduledTask['operationType']): string {
return {
'restart-service': '重启 SimAdmin 服务',
'reboot-system': '重启设备系统',
'send-sms': '发送短信',
}[value];
}
function targetLabel(task: ScheduledTask): string {
return task.targetSelector.mode === 'fixed'
? `${task.targetSelector.instanceIds.length} 个固定实例`
: `${task.targetSelector.match === 'all' ? '全部匹配' : '任一匹配'}${task.targetSelector.tags.join('、')}`;
}
function outcomeLabel(value: ScheduledRun['outcome']): string {
if (!value) return '执行中';
return {
succeeded: '成功',
'partially-succeeded': '部分成功',
failed: '失败',
skipped: '已跳过',
'no-targets': '无匹配实例',
'needs-attention': '需要处理',
}[value];
}
interface EditorForm {
name: string;
operationType: ScheduledTask['operationType'];
targetMode: 'fixed' | 'tags';
fixedIds: string;
tags: string;
tagMatch: 'any' | 'all';
recipients: string;
content: string;
cronExpression: string;
misfirePolicy: ScheduledTask['misfirePolicy'];
overlapPolicy: ScheduledTask['overlapPolicy'];
maxRetries: number;
retryInterval: number;
effectiveStart: string;
effectiveEnd: string;
}
const defaults: EditorForm = {
name: '',
operationType: 'restart-service' as ScheduledTask['operationType'],
targetMode: 'fixed' as 'fixed' | 'tags',
fixedIds: '',
tags: '',
tagMatch: 'any' as 'any' | 'all',
recipients: '',
content: '',
cronExpression: '0 9 * * *',
misfirePolicy: 'skip',
overlapPolicy: 'skip',
maxRetries: 0,
retryInterval: 60,
effectiveStart: '',
effectiveEnd: '',
};
export function AutomationPage({
dataSource,
initialTab = 'schedules',
runsContent,
recordsContent,
}: AutomationPageProps) {
const [tab, setTab] = useState<AutomationTab>(initialTab);
const [tasks, setTasks] = useState<readonly ScheduledTask[]>([]);
const [runs, setRuns] = useState<readonly ScheduledRun[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editorOpen, setEditorOpen] = useState(false);
const [editingTask, setEditingTask] = useState<ScheduledTask>();
const [form, setForm] = useState(defaults);
const [preview, setPreview] = useState<readonly string[]>([]);
const [saving, setSaving] = useState(false);
const [confirmedFrequency, setConfirmedFrequency] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [confirmedDefinition, setConfirmedDefinition] = useState(false);
const [actionMenuId, setActionMenuId] = useState<string>();
const drawerRef = useRef<HTMLElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => setTab(initialTab), [initialTab]);
const load = () => {
const controller = new AbortController();
setLoading(true);
setError('');
void Promise.all([
dataSource.listSchedules(controller.signal),
dataSource.listRuns(controller.signal),
]).then(
([nextTasks, nextRuns]) => {
setTasks(nextTasks);
setRuns(nextRuns);
setLoading(false);
},
() => {
setError('无法加载自动化数据,请稍后重试。');
setLoading(false);
},
);
return () => controller.abort();
};
useEffect(load, [dataSource]);
useEffect(() => {
if (!editorOpen) return;
drawerRef.current?.querySelector<HTMLElement>('input, select, textarea, button')?.focus();
}, [editorOpen]);
const highFrequencyRestart =
form.operationType === 'restart-service' &&
/^\*|^\*\/([1-5])(?:\s|$)/.test(form.cronExpression);
function field<K extends keyof typeof defaults>(key: K, value: (typeof defaults)[K]) {
setForm((current) => ({ ...current, [key]: value }));
setReviewing(false);
setConfirmedDefinition(false);
}
function closeEditor() {
setEditorOpen(false);
setEditingTask(undefined);
setForm(defaults);
setPreview([]);
setConfirmedFrequency(false);
setReviewing(false);
setConfirmedDefinition(false);
queueMicrotask(() => returnFocusRef.current?.focus());
}
function createTask() {
returnFocusRef.current = document.activeElement as HTMLElement | null;
setEditingTask(undefined);
setForm(defaults);
setPreview([]);
setConfirmedFrequency(false);
setReviewing(false);
setConfirmedDefinition(false);
setEditorOpen(true);
}
function editTask(task: ScheduledTask) {
setEditingTask(task);
setForm({
name: task.name,
operationType: task.operationType,
targetMode: task.targetSelector.mode,
fixedIds:
task.targetSelector.mode === 'fixed' ? task.targetSelector.instanceIds.join(', ') : '',
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
recipients: '',
content: '',
cronExpression: task.cronExpression,
misfirePolicy: task.misfirePolicy,
overlapPolicy: task.overlapPolicy,
maxRetries: task.retryPolicy.maxRetries,
retryInterval: task.retryPolicy.intervalSeconds,
effectiveStart: beijingLocal(task.effectiveStartAt),
effectiveEnd: beijingLocal(task.effectiveEndAt),
});
setPreview([]);
setConfirmedFrequency(false);
setReviewing(false);
setConfirmedDefinition(false);
setEditorOpen(true);
}
function handleEditorKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === 'Escape') {
event.preventDefault();
closeEditor();
return;
}
if (event.key !== 'Tab') return;
const focusable = drawerRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (highFrequencyRestart && (preview.length !== 5 || !confirmedFrequency)) return;
const split = (value: string) =>
value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const effectiveStartAt = beijingIso(form.effectiveStart);
const effectiveEndAt = beijingIso(form.effectiveEnd);
const smsChanged = form.recipients.trim().length > 0 || form.content.length > 0;
const request: CreateScheduledTaskRequest = {
name: form.name.trim(),
operationType: form.operationType,
cronExpression: form.cronExpression.trim(),
timezone: 'Asia/Shanghai',
targetSelector:
form.targetMode === 'fixed'
? { mode: 'fixed', instanceIds: split(form.fixedIds) }
: { mode: 'tags', match: form.tagMatch, tags: split(form.tags) },
...(form.operationType === 'send-sms' && (!editingTask?.sms || smsChanged)
? { sms: { recipients: split(form.recipients), content: form.content } }
: {}),
...(effectiveStartAt ? { effectiveStartAt } : {}),
...(effectiveEndAt ? { effectiveEndAt } : {}),
misfirePolicy: form.misfirePolicy,
overlapPolicy: form.overlapPolicy,
retryPolicy: { maxRetries: form.maxRetries, intervalSeconds: form.retryInterval },
enabled: editingTask?.enabled ?? true,
};
if (!reviewing) {
setReviewing(true);
setConfirmedDefinition(false);
return;
}
if (!confirmedDefinition) return;
setSaving(true);
setError('');
try {
if (editingTask) {
const { sms: requestSms, ...common } = request;
const updated = await dataSource.updateSchedule(editingTask.id, editingTask.version, {
...common,
...(requestSms ? { sms: requestSms } : {}),
effectiveStartAt: effectiveStartAt ?? null,
effectiveEndAt: effectiveEndAt ?? null,
});
setTasks((current) => current.map((item) => (item.id === updated.id ? updated : item)));
} else {
const created = await dataSource.createSchedule(request);
setTasks((current) => [created, ...current]);
}
closeEditor();
} catch {
setError('任务保存失败,请检查表单后重试。');
} finally {
setSaving(false);
}
}
async function toggle(task: ScheduledTask, enabled: boolean) {
const updated = await dataSource.setEnabled(task.id, task.version, enabled);
if (updated)
setTasks((current) => current.map((item) => (item.id === task.id ? updated : item)));
}
async function remove(task: ScheduledTask) {
await dataSource.removeSchedule(task.id, task.version);
setTasks((current) => current.filter((item) => item.id !== task.id));
}
async function duplicate(task: ScheduledTask) {
const created = await dataSource.duplicateSchedule(task.id, task.version);
setTasks((current) => [created, ...current]);
}
const tabs: readonly [AutomationTab, string][] = [
['schedules', '计划任务'],
['runs', '执行记录'],
['records', '操作审计'],
];
return (
<section className="automation-workspace" aria-labelledby="automation-title">
<header className="workbench-heading">
<div>
<h1 id="automation-title"></h1>
<p>UTC+8</p>
</div>
{tab === 'schedules' ? (
<button type="button" className="primary-action" onClick={createTask}>
</button>
) : null}
</header>
<div className="workbench-tabs" role="tablist" aria-label="自动化视图">
{tabs.map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
>
{label}
</button>
))}
</div>
{error ? (
<p className="state-panel state-error" role="alert">
{error}
</p>
) : null}
{tab === 'schedules' ? (
<div role="tabpanel" aria-label="计划任务">
{loading ? <p role="status">...</p> : null}
{!loading && tasks.length === 0 ? (
<div className="automation-empty">
<strong></strong>
<span></span>
</div>
) : null}
{tasks.length > 0 ? (
<div className="schedule-table-wrap">
<table className="schedule-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>Cron</th>
<th></th>
<th></th>
<th>
<span className="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id}>
<th scope="row">
<strong>{task.name}</strong>
<small>v{task.version}</small>
</th>
<td>
<span className="operation-chip" data-operation={task.operationType}>
{operationLabel(task.operationType)}
</span>
</td>
<td>{targetLabel(task)}</td>
<td>
<code>{task.cronExpression}</code>
</td>
<td>
{task.enabled && task.nextDueAt
? `${beijingTime(task.nextDueAt)} CST`
: '已暂停'}
</td>
<td>
<label className="compact-switch">
<input
type="checkbox"
checked={task.enabled}
aria-label={`启用 ${task.name}`}
onChange={(event) => void toggle(task, event.currentTarget.checked)}
/>
<span aria-hidden="true" />
</label>
</td>
<td>
<div className="row-action-menu">
<button
type="button"
className="row-action-trigger"
aria-label={`任务操作 ${task.name}`}
aria-haspopup="menu"
aria-expanded={actionMenuId === task.id}
onClick={(event) => {
returnFocusRef.current = event.currentTarget;
setActionMenuId((current) =>
current === task.id ? undefined : task.id,
);
}}
>
<Icon name="more" />
</button>
{actionMenuId === task.id ? (
<div
className="row-actions"
role="menu"
aria-label={`${task.name} 操作`}
>
<button
type="button"
role="menuitem"
aria-label={`编辑 ${task.name}`}
onClick={() => {
setActionMenuId(undefined);
editTask(task);
}}
>
</button>
<button
type="button"
role="menuitem"
aria-label={`复制 ${task.name}`}
onClick={() => {
setActionMenuId(undefined);
void duplicate(task);
}}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setActionMenuId(undefined);
void dataSource.runNow(task.id);
}}
>
</button>
<button
type="button"
role="menuitem"
className="danger-link"
onClick={() => {
setActionMenuId(undefined);
void remove(task);
}}
>
</button>
</div>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
) : null}
{tab === 'runs' ? (
<div role="tabpanel" aria-label="执行记录">
{runsContent ??
(runs.length === 0 ? (
<div className="automation-empty">
<strong></strong>
<span></span>
</div>
) : (
<div className="schedule-table-wrap">
<table className="schedule-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{beijingTime(run.dueAt)} CST</td>
<th scope="row">{run.taskName}</th>
<td>{operationLabel(run.operationType)}</td>
<td>{run.targetSnapshot.length}</td>
<td>{outcomeLabel(run.outcome)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
) : null}
{tab === 'records' ? (
<div role="tabpanel" aria-label="操作审计">
{recordsContent ?? (
<div className="automation-empty">
<strong></strong>
<span></span>
</div>
)}
</div>
) : null}
{editorOpen ? (
<div
className="drawer-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeEditor();
}}
>
<aside
ref={drawerRef}
className="schedule-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="schedule-editor-title"
onKeyDown={handleEditorKeyDown}
>
<header>
<div>
<span className="drawer-eyebrow">{editingTask ? '任务设置' : '新建自动化'}</span>
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="关闭任务编辑器"
onClick={closeEditor}
>
<Icon name="close" />
</button>
</header>
<form onSubmit={(event) => void submit(event)}>
<section>
<h3></h3>
<label>
<span></span>
<input
required
value={form.name}
onChange={(event) => field('name', event.currentTarget.value)}
/>
</label>
<label>
<span></span>
<select
value={form.operationType}
onChange={(event) =>
field('operationType', event.currentTarget.value as typeof form.operationType)
}
>
<option value="restart-service"> SimAdmin </option>
<option value="reboot-system"></option>
<option value="send-sms"></option>
</select>
</label>
</section>
<section>
<h3></h3>
<label>
<span></span>
<select
value={form.targetMode}
onChange={(event) =>
field('targetMode', event.currentTarget.value as typeof form.targetMode)
}
>
<option value="fixed"></option>
<option value="tags"></option>
</select>
</label>
{form.targetMode === 'fixed' ? (
<label>
<span> ID</span>
<input
required
value={form.fixedIds}
placeholder="node-a, node-b"
onChange={(event) => field('fixedIds', event.currentTarget.value)}
/>
</label>
) : (
<>
<label>
<span></span>
<select
value={form.tagMatch}
onChange={(event) =>
field('tagMatch', event.currentTarget.value as typeof form.tagMatch)
}
>
<option value="any"></option>
<option value="all"></option>
</select>
</label>
<label>
<span></span>
<input
required
value={form.tags}
placeholder="lab, east"
onChange={(event) => field('tags', event.currentTarget.value)}
/>
</label>
</>
)}
</section>
{form.operationType === 'send-sms' ? (
<section>
<h3></h3>
{editingTask?.sms ? (
<p className="field-note"></p>
) : null}
<label>
<span></span>
<input
required={!editingTask?.sms}
value={form.recipients}
placeholder={
editingTask?.sms ? '留空以保留当前收件号码' : '13800138000, 13900139000'
}
onChange={(event) => field('recipients', event.currentTarget.value)}
/>
</label>
<label>
<span></span>
<textarea
required={!editingTask?.sms}
rows={4}
value={form.content}
placeholder={editingTask?.sms ? '留空以保留当前短信内容' : undefined}
onChange={(event) => field('content', event.currentTarget.value)}
/>
</label>
</section>
) : null}
<section>
<h3></h3>
<label>
<span> Cron </span>
<div className="inline-field">
<input
required
value={form.cronExpression}
onChange={(event) => {
field('cronExpression', event.currentTarget.value);
setPreview([]);
}}
/>
<button
type="button"
onClick={() =>
void dataSource.previewCron(form.cronExpression).then((items) => {
setPreview(items);
setConfirmedFrequency(false);
})
}
>
</button>
</div>
</label>
<p className="field-note"> · Asia/Shanghai · UTC+8</p>
<div className="advanced-grid schedule-window">
<label>
<span></span>
<input
type="datetime-local"
value={form.effectiveStart}
onChange={(event) => field('effectiveStart', event.currentTarget.value)}
/>
</label>
<label>
<span></span>
<input
type="datetime-local"
value={form.effectiveEnd}
onChange={(event) => field('effectiveEnd', event.currentTarget.value)}
/>
</label>
</div>
{preview.length ? (
<ol className="cron-preview">
{preview.map((item) => (
<li key={item}>{beijingTime(item)} CST</li>
))}
</ol>
) : null}
{highFrequencyRestart ? (
<label className="risk-confirm">
<input
type="checkbox"
checked={confirmedFrequency}
disabled={preview.length !== 5}
onChange={(event) => setConfirmedFrequency(event.currentTarget.checked)}
/>
<span> 5 </span>
</label>
) : null}
</section>
<details>
<summary></summary>
<div className="advanced-grid">
<label>
<span></span>
<select
value={form.misfirePolicy}
onChange={(event) =>
field(
'misfirePolicy',
event.currentTarget.value as typeof form.misfirePolicy,
)
}
>
<option value="skip"></option>
<option value="catch-up-once"></option>
</select>
</label>
<label>
<span></span>
<select
value={form.overlapPolicy}
onChange={(event) =>
field(
'overlapPolicy',
event.currentTarget.value as typeof form.overlapPolicy,
)
}
>
<option value="skip"></option>
<option value="queue-once"></option>
</select>
</label>
<label>
<span></span>
<input
type="number"
min="0"
max={form.operationType === 'reboot-system' ? 0 : 10}
value={form.maxRetries}
onChange={(event) => field('maxRetries', Number(event.currentTarget.value))}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
max="86400"
value={form.retryInterval}
onChange={(event) =>
field('retryInterval', Number(event.currentTarget.value))
}
/>
</label>
</div>
</details>
{reviewing ? (
<section
className="schedule-confirmation"
role="group"
aria-labelledby="schedule-confirmation-title"
>
<h3 id="schedule-confirmation-title"></h3>
<dl>
<div>
<dt></dt>
<dd>{operationLabel(form.operationType)}</dd>
</div>
<div>
<dt></dt>
<dd>
{form.targetMode === 'fixed'
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
</dd>
</div>
<div>
<dt></dt>
<dd>
<code>{form.cronExpression.trim()}</code> ·
</dd>
</div>
<div>
<dt></dt>
<dd>{form.operationType === 'reboot-system' ? 'R3' : 'R2'}</dd>
</div>
</dl>
<label className="risk-confirm">
<input
type="checkbox"
checked={confirmedDefinition}
onChange={(event) => setConfirmedDefinition(event.currentTarget.checked)}
/>
<span>Cron </span>
</label>
</section>
) : null}
<footer>
<button type="button" onClick={closeEditor}>
</button>
<button
type="submit"
className="primary-action"
disabled={
saving ||
(highFrequencyRestart && (preview.length !== 5 || !confirmedFrequency)) ||
(reviewing && !confirmedDefinition)
}
>
{saving
? '正在保存...'
: reviewing
? editingTask
? '确认并保存'
: '确认并创建'
: '检查并继续'}
</button>
</footer>
</form>
</aside>
</div>
) : null}
</section>
);
}
@@ -14,6 +14,7 @@ describe('Fleet API data source', () => {
memoryPercent: 67.8,
maxTemperatureCelsius: 52.6,
phoneNumbers: ['13800000000'],
version: '1.9.4',
}
: {
items: [
@@ -36,7 +37,13 @@ describe('Fleet API data source', () => {
);
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
expect(snapshot.instances).toEqual([
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'], revision: 4 },
{
id: 'alpha',
name: 'Alpha',
url: 'https://alpha.example/admin',
tags: ['lab'],
revision: 4,
},
]);
expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({
cpuPercent: 23.4,
@@ -44,6 +51,7 @@ describe('Fleet API data source', () => {
maxTemperatureCelsius: 52.6,
phoneNumbers: ['13800000000'],
});
expect(snapshot.statuses.get('alpha')?.summary?.version).toBe('1.9.4');
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/instances',
expect.objectContaining({ credentials: 'same-origin' }),
@@ -81,12 +89,15 @@ describe('Fleet API data source', () => {
);
});
const partials: unknown[] = [];
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(undefined, (snapshot) => {
partials.push({
resources: snapshot.statuses.get('alpha')?.summary?.resources,
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
});
});
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(
undefined,
(snapshot) => {
partials.push({
resources: snapshot.statuses.get('alpha')?.summary?.resources,
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
});
},
);
await vi.waitFor(() => expect(partials.length).toBe(1));
expect(partials[0]).toEqual({ resources: undefined, freshness: 'unknown' });
resolveResources(
+7 -6
View File
@@ -41,6 +41,11 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
maxTemperatureCelsius?: number;
phoneNumbers?: string[];
} = {};
const version = string(body.version)
? body.version
: string(record(body.health)?.version)
? (record(body.health)?.version as string)
: undefined;
if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent;
if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent;
if (finite(body.maxTemperatureCelsius))
@@ -52,6 +57,7 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
resources.phoneNumbers = body.phoneNumbers as string[];
return {
freshness: 'fresh',
...(version ? { version } : {}),
...(Object.keys(resources).length > 0 ? { resources } : {}),
};
}
@@ -87,12 +93,7 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
...(signal ? { signal } : {}),
});
const listBody = record(await readJson(listResponse));
if (
!listResponse.ok ||
!listBody ||
!Array.isArray(listBody.items) ||
!record(listBody.page)
)
if (!listResponse.ok || !listBody || !Array.isArray(listBody.items) || !record(listBody.page))
throw new Error('Fleet response is invalid.');
const instances = listBody.items.map(parseInstance);
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
+155 -18
View File
@@ -20,7 +20,11 @@ const snapshot: FleetSnapshot = {
{
reachable: true,
authenticated: true,
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
summary: {
version: '1.1.6',
freshness: 'fresh',
resources: { cpuPercent: 24, memoryPercent: 51, maxTemperatureCelsius: 42 },
},
},
],
]),
@@ -43,16 +47,97 @@ describe('FleetPage card navigation', () => {
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
/\s*1.*线\s*1.*\s*0/s,
);
expect(within(card).getByRole('meter', { name: 'CPU 使用率' }).getAttribute('value')).toBe(
'24',
);
expect(within(card).getByRole('meter', { name: '内存使用率' }).getAttribute('value')).toBe(
'51',
);
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
expect(
within(card).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
).toBe('24');
expect(
within(card).getByRole('progressbar', { name: '内存使用率' }).getAttribute('aria-valuenow'),
).toBe('51');
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
const nodeEntry = within(card).getByRole('link', { name: '打开 Alpha modem 节点入口' });
expect(nodeEntry.getAttribute('href')).toBe('http://192.168.1.2');
expect(nodeEntry.getAttribute('target')).toBe('_blank');
expect(nodeEntry.getAttribute('rel')).toContain('noopener');
expect(within(card).queryByRole('group', { name: '实例运维操作' })).toBeNull();
expect(within(card).queryByRole('button', { name: '重启服务 Alpha modem' })).toBeNull();
const actionTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
expect(actionTrigger.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(actionTrigger);
expect(actionTrigger.getAttribute('aria-expanded')).toBe('true');
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
const metadata = card.querySelector('.fleet-card-metadata');
const hardware = within(card).getByRole('group', { name: '节点硬件信息' });
const telemetry = within(card).getByRole('region', { name: '资源遥测' });
const footer = within(card).getByRole('region', { name: '短信状态' });
expect(metadata).toBeTruthy();
expect(
Boolean(metadata!.compareDocumentPosition(hardware) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
expect(
Boolean(hardware.compareDocumentPosition(telemetry) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
expect(
Boolean(telemetry.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
const withoutVersion: FleetSnapshot = {
...snapshot,
statuses: new Map([
[
'alpha',
{
...snapshot.statuses.get('alpha')!,
summary: {
freshness: 'fresh',
resources: { cpuPercent: 24, memoryPercent: 51 },
},
},
],
]),
};
render(<FleetPage initialData={withoutVersion} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(within(card).getByText('版本未知')).toBeTruthy();
});
it('supports the menu button keyboard model and dismisses the menu from outside', () => {
render(<FleetPage initialData={snapshot} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
const trigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
trigger.focus();
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const serviceRestart = within(card).getByRole('menuitem', {
name: '重启服务 Alpha modem',
});
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
expect(document.activeElement).toBe(serviceRestart);
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
expect(document.activeElement).toBe(systemReboot);
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
expect(document.activeElement).toBe(serviceRestart);
fireEvent.keyDown(serviceRestart, { key: 'ArrowUp' });
expect(document.activeElement).toBe(systemReboot);
fireEvent.keyDown(systemReboot, { key: 'Escape' });
expect(within(card).queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger);
expect(within(card).getByRole('menu')).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(within(card).queryByRole('menu')).toBeNull();
});
});
@@ -78,9 +163,59 @@ describe('FleetPage search and filter toolbar', () => {
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
});
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
const groupedSnapshot: FleetSnapshot = {
instances: [
{ ...snapshot.instances[0]!, tags: ['核心'] },
{
id: 'beta',
name: 'Beta modem',
url: 'http://192.168.1.3',
tags: ['外场'],
revision: 1,
},
],
statuses: new Map([
...snapshot.statuses,
['beta', { reachable: false, authenticated: false }],
]),
};
render(<FleetPage initialData={groupedSnapshot} />);
const groups = screen.getByRole('group', { name: '节点分组' });
const betaCard = screen.getByRole('article', { name: 'Beta modem 实例概览' });
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
expect(within(betaCard).getAllByText('--')).toHaveLength(3);
expect(within(groups).getByRole('button', { name: '全部节点' })).toBeTruthy();
fireEvent.click(within(groups).getByRole('button', { name: '核心' }));
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
expect(screen.queryByRole('article', { name: 'Beta modem 实例概览' })).toBeNull();
expect(
screen
.getByRole('search', { name: '实例搜索与筛选' })
.closest('aside')
?.classList.contains('fleet-sidebar'),
).toBe(true);
});
});
describe('FleetPage batch and restart actions', () => {
it('keeps selection controls hidden until batch-selection mode is entered', () => {
render(<FleetPage initialData={snapshot} />);
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
const checkbox = screen.getByRole('checkbox', { name: /选择 Alpha modem/ });
expect(checkbox).toBeTruthy();
expect(checkbox.closest('.fleet-card-header-meta')).toBeTruthy();
expect(screen.getByRole('button', { name: '全选本页' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '退出批量选择' }));
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
});
it('exposes card restart controls and batch restart entry for selected instances', async () => {
const prepare = vi.fn(async () => ({
id: 'prep-1',
@@ -123,17 +258,19 @@ describe('FleetPage batch and restart actions', () => {
}));
vi.stubGlobal('confirm', () => true);
render(
<FleetPage
initialData={snapshot}
operationClient={{ list, prepare, execute } as never}
/>,
<FleetPage initialData={snapshot} operationClient={{ list, prepare, execute } as never} />,
);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
const cardMenuTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
expect(cardMenuTrigger.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(cardMenuTrigger);
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
expect(screen.getByText(/已选择 1 项/).textContent).toMatch(/已选择 1 项/);
fireEvent.click(screen.getByRole('button', { name: '批量操作' }));
expect(
screen.getAllByText(/已选择 1 项/).some((node) => /已选择 1 项/.test(node.textContent ?? '')),
).toBe(true);
const batch = await screen.findByRole('region', { name: '批量操作入口' });
expect(batch.textContent).toMatch(/已选择 1 项/);
fireEvent.click(screen.getByRole('button', { name: '批量重启服务' }));
File diff suppressed because it is too large Load Diff
+81 -76
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import {
createInstanceApiDataSource,
@@ -146,7 +147,9 @@ export function InstanceEditor({
return (
<section className="instance-editor">
<h1>{mode === 'create' ? '添加实例' : '实例设置'}</h1>
<h1>
<Title color="app-green">{mode === 'create' ? '添加实例' : '实例设置'}</Title>
</h1>
{error ? (
<p role="alert" className="state-panel state-error">
{error}
@@ -157,82 +160,84 @@ export function InstanceEditor({
{status}
</p>
) : null}
<form onSubmit={submit}>
<label>
<input required value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<input
required
type="url"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</label>
<label>
<input
value={tags}
onChange={(event) => setTags(event.target.value)}
aria-describedby="tags-help"
/>
</label>
<small id="tags-help">使</small>
<label>
<select
value={authMethod}
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
>
<option value="none"></option>
<option value="password"></option>
</select>
</label>
{authMethod === 'password' ? (
<>
{mode === 'edit' ? (
<label>
<select
value={passwordAction}
onChange={(event) => {
setPasswordAction(event.target.value as PasswordAction);
setPassword('');
}}
>
<option value="preserve"></option>
<option value="set"></option>
<option value="clear"></option>
</select>
</label>
) : null}
{mode === 'create' || passwordAction === 'set' ? (
<label>
<input
required
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
) : null}
</>
) : null}
<div className="form-actions">
<button disabled={busy} type="submit">
{mode === 'create' ? '添加实例' : '保存更改'}
</button>
{mode === 'edit' ? (
<button disabled={busy} type="button" onClick={() => void testConnection()}>
</button>
<Card pattern="default" className="editor-card">
<form onSubmit={submit}>
<label>
<input required value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<input
required
type="url"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</label>
<label>
<input
value={tags}
onChange={(event) => setTags(event.target.value)}
aria-describedby="tags-help"
/>
</label>
<small id="tags-help">使</small>
<label>
<select
value={authMethod}
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
>
<option value="none"></option>
<option value="password"></option>
</select>
</label>
{authMethod === 'password' ? (
<>
{mode === 'edit' ? (
<label>
<select
value={passwordAction}
onChange={(event) => {
setPasswordAction(event.target.value as PasswordAction);
setPassword('');
}}
>
<option value="preserve"></option>
<option value="set"></option>
<option value="clear"></option>
</select>
</label>
) : null}
{mode === 'create' || passwordAction === 'set' ? (
<label>
<input
required
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
) : null}
</>
) : null}
</div>
</form>
<div className="form-actions">
<Button disabled={busy} loading={busy} htmlType="submit" type="primary">
{mode === 'create' ? '添加实例' : '保存更改'}
</Button>
{mode === 'edit' ? (
<Button disabled={busy} htmlType="button" onClick={() => void testConnection()}>
</Button>
) : null}
</div>
</form>
</Card>
{mode === 'edit' ? (
<section className="danger-zone" aria-labelledby="danger-heading">
<h2 id="danger-heading"></h2>
+32 -6
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Tag, Title } from 'animal-island-ui';
import type { InstanceContext, InstanceModule } from '../app-shell.js';
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
@@ -124,7 +125,9 @@ export function InstanceDetail({
if (!ownsRoute) {
return (
<section>
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
<h1>
<Title>{INSTANCE_MODULE_LABELS[module]}</Title>
</h1>
<p></p>
</section>
);
@@ -142,16 +145,35 @@ export function InstanceDetail({
</a>
<div className="instance-identity">
<div>
<h1>{instance.name}</h1>
<h1>
<Title color="app-teal">{instance.name}</Title>
</h1>
<code>{instance.id}</code>
</div>
<div className="instance-context-badges">
{instance.status !== 'unknown' ? <span>{displayStatus(instance.status)}</span> : null}
{instance.status !== 'unknown' ? (
<Tag
size="small"
color={
instance.status === 'online'
? 'app-teal'
: instance.status === 'offline'
? 'app-red'
: 'app-yellow'
}
>
{displayStatus(instance.status)}
</Tag>
) : null}
{instance.authentication !== 'unknown' ? (
<span>{AUTH_LABELS[instance.authentication]}</span>
<Tag size="small" color="app-yellow">
{AUTH_LABELS[instance.authentication]}
</Tag>
) : null}
{instance.freshness !== 'unknown' ? (
<span>{FRESHNESS_LABELS[instance.freshness]}</span>
<Tag size="small" color="app-blue">
{FRESHNESS_LABELS[instance.freshness]}
</Tag>
) : null}
</div>
</div>
@@ -185,7 +207,11 @@ export function InstanceDetail({
</nav>
</header>
<div className="instance-module-detail">
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
<h2>
<Title size="small" color="app-green">
{INSTANCE_MODULE_LABELS[module]}
</Title>
</h2>
{loading ? <p role="status"></p> : null}
{loadError ? <p role="alert">{loadError}</p> : null}
{!loading && canOpen(module, activeCapability) ? (
@@ -16,7 +16,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
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 },
{ headers: { accept: 'application/json' }, credentials: 'same-origin', signal },
);
if (!response.ok) throw new Error('message load failed');
const root = record(await response.json());
@@ -50,6 +50,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
{
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(input),
},
);
+2 -2
View File
@@ -243,12 +243,12 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
name="content"
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={1600}
maxLength={2000}
required
/>
</label>
<div className="composer-footer">
<small>{content.length} / 1600 </small>
<small>{content.length} / 2000 </small>
<button type="submit" disabled={sending || !content.trim()}>
{sending ? '正在发送…' : '发送短信'}
</button>
+16 -6
View File
@@ -1,10 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import {
createOperationClient,
type OperationClient,
} from '../operations/operation-client.js';
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js';
export type OverviewFieldValue = string | number | boolean | null;
@@ -51,11 +48,22 @@ const SECTIONS = [
const FIELD_LABELS: Readonly<Record<string, string>> = {
Model: '型号',
Manufacturer: '制造商',
IMEI: 'IMEI',
Version: '固件版本',
Uptime: '运行时间',
Slots: '卡槽数',
Active: '活跃数',
Operator: '运营商',
Technology: '接入制式',
Signal: '信号强度',
MCC: 'MCC',
MNC: 'MNC',
Registration: '注册状态',
IPv4: 'IPv4 地址',
IPv6: 'IPv6 地址',
Download: '下行速率',
Upload: '上行速率',
'Messages today': '今日消息数',
Calls: '通话数',
Usage: '使用率',
@@ -214,7 +222,8 @@ export function OverviewSystemPage({
return;
setActionState({ busy: true, message: `正在${op.title}` });
try {
await client.list({ pageSize: 100 });
// Avoid pageSize=100 first-page truncation for late-sorted operation ids.
await client.list({ search: op.operationId, pageSize: 100 });
const prepared = await client.prepare({
operationId: op.operationId,
targets: [{ instanceId: instance.id, revision: targetRevision }],
@@ -235,7 +244,8 @@ export function OverviewSystemPage({
const job = await client.execute(prepared.id);
setActionState({
busy: false,
message: job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
message:
job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }),
});
} catch (error) {
+5 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'animal-island-ui';
import { displayStatus } from '../ui/locale.js';
import {
@@ -327,13 +328,14 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
/>
</label>
<button
type="button"
<Button
htmlType="button"
size="small"
aria-label="刷新任务"
onClick={() => setManualRefresh((value) => value + 1)}
>
</button>
</Button>
</div>
{loading ? (
<p role="status" aria-label="任务加载状态">
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'animal-island-ui/style';
import { ConsoleAuthGate } from './auth/console-auth.js';
import { AppShell } from './app-shell.js';
@@ -104,8 +104,13 @@ describe('safe operation client', () => {
]);
});
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
const fetcher = vi.fn(async () => response(page));
it('refuses uncatalogued operations and mismatched schemas without a prepare request', async () => {
const fetcher = vi.fn(async (url: string) => {
if (String(url).includes('search=hiddenOp')) {
return response({ items: [], page: { page: 1, pageSize: 100, total: 0 } });
}
return response(page);
});
const client = createOperationClient(fetcher as typeof fetch);
await client.list();
@@ -123,7 +128,52 @@ describe('safe operation client', () => {
parameters: { parameterSchemaId: 'wrong', fields: [] },
}),
).rejects.toThrow('Operation parameter schema does not match the catalog.');
expect(fetcher).toHaveBeenCalledOnce();
// list + one search hydrate for the missing op; no prepare POST.
expect(fetcher.mock.calls.every((call) => !String(call[0]).includes('/prepare'))).toBe(true);
});
it('hydrates late-sorted restart ops when the first pageSize=100 page omits them', async () => {
const restartEntry = {
operationId: 'postServiceRestart',
title: 'Restart Service',
risk: 'R3',
capability: 'job',
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
};
const firstPage = {
items: [entry],
page: { page: 1, pageSize: 100, total: 117 },
};
const searchPage = {
items: [restartEntry],
page: { page: 1, pageSize: 100, total: 1 },
};
const restartPreparation = {
...preparation,
operationId: 'postServiceRestart',
risk: 'R3',
confirmationPrompt: 'Confirm restart service',
};
const fetcher = vi
.fn()
.mockResolvedValueOnce(response(firstPage))
.mockResolvedValueOnce(response(searchPage))
.mockResolvedValueOnce(response(restartPreparation));
const client = createOperationClient(fetcher as typeof fetch);
await client.list({ pageSize: 100 });
const result = await client.prepare({
operationId: 'postServiceRestart',
targets: [{ instanceId: 'instance-1', revision: 1 }],
parameters: {
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
fields: [],
},
});
expect(result.operationId).toBe('postServiceRestart');
expect(String(fetcher.mock.calls[1]?.[0])).toContain('search=postServiceRestart');
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/operations/prepare');
});
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
+29 -15
View File
@@ -369,25 +369,39 @@ function queryString(query: OperationCatalogQuery): string {
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
const catalog = new Map<string, OperationCatalogEntry>();
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
async function list(query: OperationCatalogQuery = {}): Promise<OperationCatalogPage> {
const body = await jsonResponse(
await fetcher(`/api/v1/operations${queryString(query)}`, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
const parsed = parseCatalog(body);
if (!parsed) throw new Error('Operation catalog response is invalid.');
// Merge rather than replace: a pageSize-capped first page must not wipe later lookups
// for late-sorted ids such as postServiceRestart / postSystemReboot.
for (const item of parsed.items) catalog.set(item.operationId, item);
return parsed;
}
async function ensureCatalogEntry(
operationId: string,
): Promise<OperationCatalogEntry | undefined> {
const existing = catalog.get(operationId);
if (existing) return existing;
// Catalog is sorted by operationId and capped at pageSize 100; hydrate by exact search.
await list({ search: operationId, pageSize: 100 });
return catalog.get(operationId);
}
return {
async list(query = {}) {
const body = await jsonResponse(
await fetcher(`/api/v1/operations${queryString(query)}`, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
const parsed = parseCatalog(body);
if (!parsed) throw new Error('Operation catalog response is invalid.');
catalog.clear();
for (const item of parsed.items) catalog.set(item.operationId, item);
return parsed;
},
list,
async prepare(input) {
const safeInput = safePrepareInput(input);
if (!safeInput) throw new Error('Operation preparation request is invalid.');
const allowed = catalog.get(safeInput.operationId);
const allowed = await ensureCatalogEntry(safeInput.operationId);
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
throw new Error('Operation parameter schema does not match the catalog.');
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Card, Tag, Title } from 'animal-island-ui';
import {
canonicalHttpOrigin,
@@ -199,7 +200,9 @@ export function InstanceSettingsPage({
<section aria-labelledby="settings-instances-title">
<header>
<div>
<h1 id="settings-instances-title"></h1>
<h1 id="settings-instances-title">
<Title color="app-green"></Title>
</h1>
<p> SimAdmin </p>
</div>
<a href="/instances/new"></a>
@@ -236,55 +239,70 @@ export function InstanceSettingsPage({
const authentication = authLabel(status);
return (
<li key={instance.id} aria-label={displayName}>
<h2>
<a href={`/settings/instances/${encodeURIComponent(instance.id)}`}>
{displayName}
</a>
</h2>
{instance.name ? <p>{instance.id}</p> : null}
<dl>
<div>
<dt></dt>
<dd>
{origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${displayName} 的源站`}
>
{origin}
</a>
) : (
'源地址无效'
)}
</dd>
</div>
{state ? (
<Card pattern="default" hoverable>
<h2>
<a href={`/settings/instances/${encodeURIComponent(instance.id)}`}>
{displayName}
</a>
</h2>
{instance.name ? <p>{instance.id}</p> : null}
<dl>
<div>
<dt></dt>
<dd>{state}</dd>
<dt></dt>
<dd>
{origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${displayName} 的源站`}
>
{origin}
</a>
) : (
'源地址无效'
)}
</dd>
</div>
) : null}
{authentication ? (
<div>
<dt></dt>
<dd>{authentication}</dd>
</div>
) : null}
{status?.freshness ? (
<div>
<dt></dt>
<dd>{freshnessLabel(status.freshness)}</dd>
</div>
) : null}
{status?.capabilities?.length ? (
<div>
<dt></dt>
<dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
</div>
) : null}
</dl>
{state ? (
<div>
<dt></dt>
<dd>
<Tag
size="small"
color={
state === '在线'
? 'app-teal'
: state === '离线'
? 'app-red'
: 'app-yellow'
}
>
{state}
</Tag>
</dd>
</div>
) : null}
{authentication ? (
<div>
<dt></dt>
<dd>{authentication}</dd>
</div>
) : null}
{status?.freshness ? (
<div>
<dt></dt>
<dd>{freshnessLabel(status.freshness)}</dd>
</div>
) : null}
{status?.capabilities?.length ? (
<div>
<dt></dt>
<dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
</div>
) : null}
</dl>
</Card>
</li>
);
})}
+4650 -1399
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
import type { SVGProps } from 'react';
export type IconName =
| 'activity'
| 'alert'
| 'arrow-up-right'
| 'check'
| 'close'
| 'chevron-left'
| 'chevron-right'
| 'cpu'
| 'filter'
| 'globe'
| 'grid'
| 'history'
| 'jobs'
| 'memory'
| 'message'
| 'more'
| 'phone'
| 'plus'
| 'restart'
| 'search'
| 'server'
| 'settings'
| 'tag'
| 'temperature'
| 'version'
| 'wifi';
const paths: Readonly<Record<IconName, readonly string[]>> = {
activity: ['M3 12h4l2.5-7 5 14 2.5-7H21'],
alert: [
'M12 9v4',
'M12 17h.01',
'M10.3 3.7 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z',
],
'arrow-up-right': ['M7 17 17 7', 'M7 7h10v10'],
check: ['m5 12 4 4L19 6'],
close: ['M6 6l12 12M18 6 6 18'],
'chevron-left': ['m15 18-6-6 6-6'],
'chevron-right': ['m9 18 6-6-6-6'],
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
globe: [
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
'M3 12h18',
'M12 3c2.4 2.5 3.7 5.5 3.7 9S14.4 18.5 12 21c-2.4-2.5-3.7-5.5-3.7-9S9.6 5.5 12 3Z',
],
grid: ['M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z'],
history: ['M3 12a9 9 0 1 0 3-6.7L3 8', 'M3 3v5h5', 'M12 7v5l3 2'],
jobs: ['M4 7h16v13H4z', 'M9 7V4h6v3', 'M4 12h16', 'M10 12v2h4v-2'],
memory: ['M5 7h14v10H5z', 'M8 10v4M12 10v4M16 10v4', 'M3 9h2M3 15h2M19 9h2M19 15h2'],
message: ['M4 5h16v11H8l-4 4Z', 'M8 9h8M8 12h5'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
phone: [
'M7 3h3l1.5 4-2 1.5a15 15 0 0 0 6 6L17 12.5l4 1.5v3c0 2.2-1.8 4-4 4A14 14 0 0 1 3 7c0-2.2 1.8-4 4-4Z',
],
plus: ['M12 5v14M5 12h14'],
restart: ['M20 11a8 8 0 1 0-2.3 5.7', 'M20 4v7h-7'],
search: ['M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16ZM17 17l4 4'],
server: ['M4 4h16v6H4zM4 14h16v6H4z', 'M8 7h.01M8 17h.01'],
settings: [
'M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z',
],
tag: ['M20 13 13 20l-9-9V4h7Z', 'M8.5 8.5h.01'],
temperature: ['M10 14.8V5a2 2 0 1 1 4 0v9.8a4 4 0 1 1-4 0Z', 'M12 17v-7'],
version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'],
wifi: [
'M3 8.5a14 14 0 0 1 18 0',
'M6.5 12a9 9 0 0 1 11 0',
'M10 15.5a4 4 0 0 1 4 0',
'M12 19h.01',
],
};
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...props}
>
{paths[name].map((path, index) => (
<path d={path} key={`${name}-${index}`} />
))}
</svg>
);
}
+16 -2
View File
@@ -1,6 +1,20 @@
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
export default defineConfig(({ mode }) => ({
plugins: [react()],
});
resolve: {
alias:
mode === 'test'
? [
{
find: /^animal-island-ui$/u,
replacement: fileURLToPath(
new URL('./node_modules/animal-island-ui/dist/cjs/index.cjs', import.meta.url),
),
},
]
: [],
},
}));