feat: rebuild warm operations workbench
This commit is contained in:
@@ -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) : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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]) => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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: '批量重启服务' }));
|
||||
|
||||
+734
-421
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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),
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user