Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
570edf6bb2 | ||
|
|
25c853dea8 | ||
|
|
f57ec1f48d |
@@ -7,3 +7,5 @@ npm-debug.log*
|
||||
coverage/
|
||||
apps/web/dist/
|
||||
.hermes/
|
||||
.local-run/
|
||||
artifacts/
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Compact Fleet Cards Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the Fleet aggregate health strip, restore the upstream SimAdmin version on every card, and change the responsive card matrix to 1/2/2/4/5 columns.
|
||||
|
||||
**Architecture:** Keep the existing API and Fleet view-model pipeline because `/api/v1/instances/:id/resources` already supplies `version`. Make the presentation fix in `FleetPage`, remove the now-unused aggregate calculation, and update only the final Fleet CSS cascade and browser geometry assertions.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, animal-island-ui, Vitest, Testing Library, Vite, real Chrome DevTools Protocol E2E.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Keep overview and search in the desktop left sidebar.
|
||||
- Keep per-instance CPU, memory, temperature, phone, SMS, status, tags, and operation menu.
|
||||
- Render `SimAdmin <version>` when available and `版本未知` when absent.
|
||||
- Use exactly 1/2/2/4/5 columns at 390/768/1024/1440/1920 pixels.
|
||||
- Do not change API contracts, database code, instance-detail telemetry, or unrelated screens.
|
||||
- Preserve the current cream, warm-brown, mint, shadowless card treatment.
|
||||
- The shared worktree contains unrelated changes; do not create implementation commits unless explicitly requested.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Fleet Content Contract
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Modify: `apps/web/src/app-shell.integration.test.tsx`
|
||||
- Test: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Test: `apps/web/src/app-shell.integration.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `FleetSnapshot.statuses[*].summary.version` and the accessible Fleet card/region names.
|
||||
- Produces: regression coverage for version rendering, missing-version fallback, and aggregate-strip removal.
|
||||
|
||||
- [ ] **Step 1: Add the upstream version to the Fleet fixture and assert visible card copy**
|
||||
|
||||
```tsx
|
||||
summary: {
|
||||
version: '1.1.6',
|
||||
freshness: 'fresh',
|
||||
resources: { cpuPercent: 24, memoryPercent: 51, maxTemperatureCelsius: 42 },
|
||||
},
|
||||
|
||||
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a missing-version fallback assertion using a separate card fixture**
|
||||
|
||||
```tsx
|
||||
const withoutVersion: FleetSnapshot = {
|
||||
...snapshot,
|
||||
statuses: new Map([
|
||||
['alpha', { ...snapshot.statuses.get('alpha')!, summary: { resources: { cpuPercent: 24 } } }],
|
||||
]),
|
||||
};
|
||||
render(<FleetPage initialData={withoutVersion} />);
|
||||
expect(screen.getByText('版本未知')).toBeTruthy();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update AppShell integration expectations**
|
||||
|
||||
Assert `SimAdmin 2.0` is present in the Bravo card and `节点资源健康` is absent. Remove the old expectation that intentionally hid the version text.
|
||||
|
||||
- [ ] **Step 4: Run the focused tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/fleet-page.test.tsx src/app-shell.integration.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because cards do not render `row.version`, the missing-version fallback is absent, and the aggregate region still exists.
|
||||
|
||||
### Task 2: Implement Compact Fleet Content
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||
- Test: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Test: `apps/web/src/app-shell.integration.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: existing `FleetRow.version: string | null` from `buildFleetTableViewModel`.
|
||||
- Produces: `.fleet-card-version` identity text and no `节点资源健康` region.
|
||||
|
||||
- [ ] **Step 1: Remove the aggregate-only calculation and formatting helpers**
|
||||
|
||||
Delete `average`, `healthPercent`, `healthRate`, `healthTemperature`, the `fleetHealth` memo, and the complete `.fleet-health-strip` JSX block. Keep `fleetSummary`, which owns the left-sidebar totals.
|
||||
|
||||
- [ ] **Step 2: Render the real version under the card name**
|
||||
|
||||
```tsx
|
||||
<span className="fleet-card-version">{row.version ? `SimAdmin ${row.version}` : '版本未知'}</span>
|
||||
```
|
||||
|
||||
Place it inside `.fleet-card-entry` after the `<h2>` and before the dashboard affordance so it remains part of the card identity link.
|
||||
|
||||
- [ ] **Step 3: Run the focused tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/fleet-page.test.tsx src/app-shell.integration.test.tsx
|
||||
```
|
||||
|
||||
Expected: both test files pass with no React warnings.
|
||||
|
||||
### Task 3: Lock And Implement Narrow Responsive Geometry
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/real-browser-e2e.mjs`
|
||||
- Modify: `apps/web/src/styles.css`
|
||||
- Test: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `.fleet-card-grid`, `.fleet-card-version`, `.fleet-group-tabs`, `.fleet-sidebar`, and `.fleet-card` DOM selectors.
|
||||
- Produces: deterministic 1/2/2/4/5 responsive geometry and screenshots.
|
||||
|
||||
- [ ] **Step 1: Change the E2E viewport contract before CSS**
|
||||
|
||||
```js
|
||||
{ width: 390, height: 844, columns: 1, sidebarMode: 'stacked', mobile: true },
|
||||
{ width: 768, height: 900, columns: 2, sidebarMode: 'stacked', mobile: false },
|
||||
{ width: 1024, height: 900, columns: 2, sidebarMode: 'left', mobile: false },
|
||||
{ width: 1440, height: 1000, columns: 4, sidebarMode: 'left', mobile: false },
|
||||
{ width: 1920, height: 1080, columns: 5, sidebarMode: 'left', mobile: false },
|
||||
```
|
||||
|
||||
Remove health-strip selectors and assertions. Add `.fleet-card-version` bounds and text-presence checks to the existing layout snapshot.
|
||||
|
||||
- [ ] **Step 2: Run browser E2E and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$env:E2E_SCREENSHOT_DIR='C:\Users\86135\Downloads\multi-simadmin\artifacts\compact-fleet-cards'
|
||||
corepack pnpm run test:e2e:browser
|
||||
```
|
||||
|
||||
Expected: FAIL at the 1440px column count because current CSS renders 3 columns.
|
||||
|
||||
- [ ] **Step 3: Update the final Fleet cascade**
|
||||
|
||||
Set the default desktop `.fleet-card-grid` to four columns, change the wide breakpoint to five columns, preserve two columns through 1024px, and preserve one column at 390px. Remove the unused `.fleet-health-strip` rules. Add compact `.fleet-card-version` typography with ellipsis protection and no new card shadow.
|
||||
|
||||
- [ ] **Step 4: Run browser E2E and verify GREEN**
|
||||
|
||||
Run the same command from Step 2.
|
||||
|
||||
Expected: PASS at all five viewport widths with no overflow, clipped menus, wrapped hardware values, or moved desktop sidebar.
|
||||
|
||||
### Task 4: Full Verification And Local Handoff
|
||||
|
||||
**Files:**
|
||||
|
||||
- Verify: `apps/web/src/fleet/fleet-page.tsx`
|
||||
- Verify: `apps/web/src/styles.css`
|
||||
- Verify: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: the completed Fleet UI and existing local canary gateway on port 8789.
|
||||
- Produces: fresh automated evidence, reviewed screenshots, and an accessible local build.
|
||||
|
||||
- [ ] **Step 1: Run the full Web quality gates**
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web test
|
||||
corepack pnpm --filter @multi-simadmin/web typecheck
|
||||
corepack pnpm lint
|
||||
corepack pnpm format:check
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Inspect generated screenshots**
|
||||
|
||||
Review 390px, 1440px, and 1920px captures. Confirm compact cards, readable versions, no aggregate strip, no overlap, and no excessive horizontal gutters.
|
||||
|
||||
- [ ] **Step 3: Rebuild after E2E cleanup and verify local responses**
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/
|
||||
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/api/v1/instances
|
||||
```
|
||||
|
||||
Expected: both URLs return HTTP 200 and the UI is available at `http://127.0.0.1:8789/`.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Fluid Wide Workbench Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove fixed-width outer whitespace from all top-level workspaces and render four Fleet columns at 1920px without changing existing narrower layouts.
|
||||
|
||||
**Architecture:** Consolidate top-level width ownership in the canonical `.app-layout` rule and delete obsolete width constraints from later visual experiments. Extend the existing real-Chrome acceptance test to measure the application container on Nodes, Automation, and Settings at 1920px, while leaving focused-content limits untouched.
|
||||
|
||||
**Tech Stack:** CSS Grid, React 19, Vite, Chrome DevTools Protocol E2E.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Nodes, Automation, and Settings use a fluid top-level container with 16px to 24px desktop gutters.
|
||||
- Fleet remains one column at 390px, two at 768px and 1024px, three at 1440px, and four at 1920px.
|
||||
- The 17rem Fleet sidebar and its responsive stacking behavior remain unchanged.
|
||||
- Forms, dialogs, drawers, message bubbles, and empty states retain their existing content-specific width limits.
|
||||
- Do not add dependencies or change application behavior.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock and implement fluid wide-screen geometry
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/real-browser-e2e.mjs:384`
|
||||
- Modify: `apps/web/src/styles.css:1946`
|
||||
- Modify: `apps/web/src/styles.css:2917`
|
||||
- Modify: `apps/web/src/styles.css:3238`
|
||||
- Modify: `apps/web/src/styles.css:4178`
|
||||
- Test: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `.app-layout`, `.app-layout-single`, `.fleet-card-grid`, and the existing Chrome viewport matrix.
|
||||
- Produces: `pageLayout(cdp)` geometry for reusable outer-gutter assertions and a 1920px four-column Fleet layout.
|
||||
|
||||
- [x] **Step 1: Extend the browser geometry probe**
|
||||
|
||||
Add a helper that returns the viewport width and `.app-layout` bounds:
|
||||
|
||||
```js
|
||||
async function pageLayout(cdp) {
|
||||
return evaluate(
|
||||
cdp,
|
||||
`(() => { const layout = document.querySelector('.app-layout'); if (!layout) return null; const rect = layout.getBoundingClientRect(); return { viewport: innerWidth, left: rect.left, right: rect.right, width: rect.width }; })()`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Add an assertion helper that requires both outer gaps to be at least 16px and no more than 24px:
|
||||
|
||||
```js
|
||||
async function assertWidePageGutters(cdp, label) {
|
||||
const layout = await pageLayout(cdp);
|
||||
assert.ok(layout, `${label} application layout must render`);
|
||||
const rightGap = layout.viewport - layout.right;
|
||||
assert.equal(
|
||||
layout.left >= 16 && layout.left <= 24 && rightGap >= 16 && rightGap <= 24,
|
||||
true,
|
||||
`${label} must keep 16px to 24px outer gutters: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Add 1920px acceptance coverage**
|
||||
|
||||
Add `{ width: 1920, height: 1080, columns: 4, sidebarMode: 'left', mobile: false }` to `viewportCases`. After the Fleet interaction checks, assert wide gutters for Nodes, navigate to Settings and assert them there, then navigate to Automation and assert them there before the existing drawer flow changes the viewport to 390px.
|
||||
|
||||
- [x] **Step 3: Run RED verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
node scripts/real-browser-e2e.mjs --clean-dist
|
||||
```
|
||||
|
||||
Expected: FAIL at 1920px because Fleet still computes three columns or because `.app-layout` leaves approximately 224px on each side.
|
||||
|
||||
- [x] **Step 4: Consolidate top-level container ownership**
|
||||
|
||||
Make the canonical rule cover both layout variants:
|
||||
|
||||
```css
|
||||
.app-layout,
|
||||
.app-layout.app-layout-single {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: clamp(0.85rem, 1.35vw, 1.45rem) clamp(0.8rem, 1.6vw, 1.75rem);
|
||||
}
|
||||
```
|
||||
|
||||
Remove top-level `width: min(100%, 92rem)`, `max-width: 92rem`, `width: min(100%, 106rem)`, and centering declarations from the obsolete `.app-layout` experiment blocks. Keep their unrelated padding and minimum-height declarations intact.
|
||||
|
||||
Add the wide Fleet breakpoint after the base grid rule:
|
||||
|
||||
```css
|
||||
@media (min-width: 112rem) {
|
||||
.fleet-card-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 5: Run GREEN verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web test
|
||||
corepack pnpm --filter @multi-simadmin/web typecheck
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
node scripts/real-browser-e2e.mjs --clean-dist
|
||||
corepack pnpm lint
|
||||
```
|
||||
|
||||
Expected: Web tests, typecheck, build, lint, and real Chrome E2E pass. The browser test reports correct 390/768/1024/1440/1920 layouts without horizontal overflow.
|
||||
|
||||
- [x] **Step 6: Inspect wide-screen screenshots and restore the local build**
|
||||
|
||||
Run E2E with `E2E_SCREENSHOT_DIR=artifacts/fluid-wide-review`, inspect the 1440px and 1920px Fleet screenshots, then rebuild without `--clean-dist`. Verify `http://127.0.0.1:8789/` and `/api/v1/automation/schedules` return 200 and that the served CSS contains `repeat(4,minmax(0,1fr))`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Komari-Inspired Fleet Workbench Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Improve Fleet density and scanability with a five-metric resource strip, tag groups, and a flatter node-card hierarchy while preserving the approved left sidebar and fluid responsive geometry.
|
||||
|
||||
**Architecture:** Extend `FleetPage` using values already present in `FleetSnapshot` and `messageStates`, then keep all new visual ownership in the existing final Fleet cascade section. Existing search, advanced tag filtering, operations, and responsive grid contracts remain the source of behavior.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, animal-island-ui, CSS Grid, Vitest, Testing Library, Chrome DevTools Protocol E2E.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Keep overview and search in the desktop left sidebar.
|
||||
- Keep 16px to 24px outer gutters and the existing 390 / 768 / 1024 / 1440 / 1920 responsive matrix.
|
||||
- Use only existing snapshot and message-summary data; render missing measurements as `--`.
|
||||
- Do not add dependencies, backgrounds, glass cards, diagonal stripes, fixed footers, or another theme override block.
|
||||
- Keep checkboxes hidden outside batch mode and position them beside card status in batch mode.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock Fleet information architecture in component tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `FleetSnapshot`, `FleetMessageLoadState`, existing `tag` filter state, and `Progress`.
|
||||
- Produces: `.fleet-health-strip`, `.fleet-group-tabs`, `.fleet-card-facts`, `.fleet-card-resources`, and stable accessible region/group names.
|
||||
|
||||
- [x] **Step 1: Write failing component tests**
|
||||
|
||||
Add assertions for a `节点资源健康` region containing `在线率`, `平均 CPU`, `平均内存`, `最高温度`, and `短信通道`; verify tag-group buttons filter cards; verify card facts precede resource rows and resource rows precede latest SMS; verify the checkbox is inside `.fleet-card-header-meta` only in batch mode.
|
||||
|
||||
- [x] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web test -- src/fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: FAIL because the health strip, tag-group row, facts/resources structure, and header checkbox placement do not exist.
|
||||
|
||||
- [x] **Step 3: Implement the minimal React structure**
|
||||
|
||||
Compute known CPU, memory, and temperature aggregates with `useMemo`; derive online rate and readable SMS-channel count; render missing aggregate values as `--`. Add tag buttons that reuse `setTag`, move the batch checkbox into `.fleet-card-header-meta`, place metadata tags before facts, and split facts from full-width CPU/memory resource rows.
|
||||
|
||||
- [x] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web test -- src/fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: all Fleet tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Consolidate the warm, dense visual hierarchy
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/styles.css`
|
||||
- Modify: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: the Task 1 class names and the existing `.fleet-workspace`, `.fleet-sidebar`, `.fleet-card-grid`, and `.app-layout` contracts.
|
||||
- Produces: one final Fleet style owner and browser geometry checks for the new structures.
|
||||
|
||||
- [x] **Step 1: Add failing Chrome geometry assertions**
|
||||
|
||||
Extend `fleetLayout(cdp)` to return health-strip, group-row, first-card, first-resource-row, and checkbox bounds. Require each structure to remain within the results pane and viewport; in batch mode require the checkbox center to sit in the card's upper-right quadrant.
|
||||
|
||||
- [x] **Step 2: Run browser verification and verify RED**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web build` followed by `node scripts/real-browser-e2e.mjs --clean-dist`.
|
||||
|
||||
Expected: FAIL because the new structures do not yet have stable responsive geometry.
|
||||
|
||||
- [x] **Step 3: Implement styles in the existing final cascade section**
|
||||
|
||||
Style the health strip as a compact five-column hairline surface, the tag row as an overflow-safe segmented control, and cards as flat warm panels with a 12px radius. Make facts a compact two-column rail, resources two full-width aligned rows, and checkbox/status a stable upper-right group. Add 960px and 560px adaptations without changing the established card-column breakpoints.
|
||||
|
||||
- [x] **Step 4: Run complete verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web test
|
||||
corepack pnpm --filter @multi-simadmin/web typecheck
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
$env:E2E_SCREENSHOT_DIR='artifacts/komari-fleet-review'; node scripts/real-browser-e2e.mjs --clean-dist
|
||||
corepack pnpm lint
|
||||
corepack pnpm exec prettier --check apps/web/src/fleet/fleet-page.tsx apps/web/src/fleet/fleet-page.test.tsx apps/web/src/styles.css scripts/real-browser-e2e.mjs docs/superpowers/specs/2026-07-30-komari-inspired-fleet-workbench-design.md docs/superpowers/plans/2026-07-30-komari-inspired-fleet-workbench.md
|
||||
```
|
||||
|
||||
Expected: tests, typecheck, build, Chrome E2E, lint, and formatting pass.
|
||||
|
||||
- [x] **Step 5: Inspect screenshots and restore the local build**
|
||||
|
||||
Inspect 390px, 1440px, and 1920px Fleet screenshots for clipping, hierarchy, and card density. Rebuild without `--clean-dist`, confirm `http://127.0.0.1:8789/` returns 200, and confirm the automation schedules endpoint remains reachable.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Restore Fleet Sidebar Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore the Fleet overview and search controls to a persistent left sidebar on desktop while preserving a compact stacked disclosure on tablet and mobile.
|
||||
|
||||
**Architecture:** Keep the existing `FleetPage` DOM and state because it already separates the sidebar from the results pane and provides a working disclosure control. Correct only the final CSS cascade that currently overrides the two-column layout, then extend the real-browser geometry checks so future style changes cannot silently move the sidebar back into the main flow.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, CSS Grid, Vitest, Chrome DevTools Protocol E2E.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve the warm cream, brown, and mint workbench palette and restrained Apple-style hierarchy.
|
||||
- Do not change Fleet filtering, sorting, selection, or operation behavior.
|
||||
- Keep desktop sidebar collapse behavior and mobile/tablet stacked disclosure behavior accessible.
|
||||
- Keep the existing viewport acceptance matrix: 390px, 768px, 1024px, and 1440px.
|
||||
- Do not add dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the responsive Fleet geometry and restore the sidebar
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/real-browser-e2e.mjs:384`
|
||||
- Modify: `apps/web/src/styles.css:4185`
|
||||
- Test: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Existing `.fleet-workspace`, `.fleet-sidebar`, `.fleet-sidebar-header`, `.fleet-sidebar-content`, `.fleet-results-pane`, and `.fleet-card-grid` DOM classes.
|
||||
- Produces: A desktop two-column geometry above 60rem and a stacked disclosure at 60rem and below, verified through the existing `fleetLayout(cdp)` helper.
|
||||
|
||||
- [x] **Step 1: Extend the browser layout snapshot**
|
||||
|
||||
Update `fleetLayout(cdp)` to return the workspace display mode plus sidebar and results bounding rectangles:
|
||||
|
||||
```js
|
||||
const workspace = document.querySelector('.fleet-workspace');
|
||||
const sidebar = document.querySelector('.fleet-sidebar');
|
||||
const results = document.querySelector('.fleet-results-pane');
|
||||
return {
|
||||
workspaceDisplay: workspace ? getComputedStyle(workspace).display : null,
|
||||
sidebar: sidebar ? sidebar.getBoundingClientRect().toJSON() : null,
|
||||
results: results ? results.getBoundingClientRect().toJSON() : null,
|
||||
sidebarHeaderDisplay: document.querySelector('.fleet-sidebar-header')
|
||||
? getComputedStyle(document.querySelector('.fleet-sidebar-header')).display
|
||||
: null,
|
||||
};
|
||||
```
|
||||
|
||||
- [x] **Step 2: Add viewport-specific assertions**
|
||||
|
||||
For 1024px and 1440px, assert `workspaceDisplay === 'grid'`, `sidebar.right < results.left`, and `sidebarHeaderDisplay !== 'none'`. For 390px and 768px, assert `sidebar.top < results.top`, the horizontal spans overlap, and the sidebar heading remains visible.
|
||||
|
||||
- [x] **Step 3: Run the browser test and verify the regression is detected**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web build && node scripts/real-browser-e2e.mjs --clean-dist`
|
||||
|
||||
Expected: FAIL at the desktop geometry assertion because the final CSS currently computes `.fleet-workspace` as `display: block`.
|
||||
|
||||
- [x] **Step 4: Restore the final CSS cascade**
|
||||
|
||||
Replace the final single-column Fleet overrides with explicit responsive rules:
|
||||
|
||||
```css
|
||||
.fleet-workspace,
|
||||
.fleet-workspace.is-sidebar-collapsed {
|
||||
display: grid;
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: clamp(0.9rem, 1.25vw, 1.25rem);
|
||||
}
|
||||
|
||||
.fleet-workspace.is-sidebar-collapsed {
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.fleet-sidebar {
|
||||
position: sticky;
|
||||
top: 5.3rem;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(147, 127, 95, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(250, 247, 232, 0.78);
|
||||
box-shadow: 0 8px 28px rgba(77, 62, 43, 0.06);
|
||||
backdrop-filter: blur(22px) saturate(130%);
|
||||
}
|
||||
|
||||
.fleet-sidebar-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.fleet-workspace,
|
||||
.fleet-workspace.is-sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fleet-sidebar,
|
||||
.fleet-sidebar.is-collapsed {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Retain the existing collapsed-content rules and card-column breakpoints so behavior remains unchanged.
|
||||
|
||||
- [x] **Step 5: Run focused verification**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web test -- fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web build && node scripts/real-browser-e2e.mjs --clean-dist`
|
||||
|
||||
Expected: PASS at all four viewport sizes, with no horizontal overflow and the intended 1/2/2/3 card columns.
|
||||
|
||||
- [x] **Step 6: Review generated screenshots**
|
||||
|
||||
Run the browser test with `E2E_SCREENSHOT_DIR=artifacts/fleet-sidebar-review` and inspect the 390px, 768px, 1024px, and 1440px Fleet screenshots. Confirm that the summary and search are left of results on desktop, stacked above results at smaller widths, and that no text or controls overlap.
|
||||
@@ -0,0 +1,412 @@
|
||||
# Warm Operations Workbench Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deliver a warm, compact operations UI and a durable Beijing-time automation center for service restarts, system reboots, and SMS delivery across fixed or tag-selected instances.
|
||||
|
||||
**Architecture:** Add strict shared contracts, a versioned SQLite schedule store, a Cron/time service, and a lifecycle-managed coordinator that claims immutable run occurrences before dispatch. Reuse the existing instance, Job, Audit, secret-store, and secure-operation boundaries, then expose the behavior through authenticated Fastify routes and a React Automation workspace. The frontend keeps Jobs and Audit as secondary Automation tabs while Fleet becomes a compact responsive node grid with opt-in batch selection.
|
||||
|
||||
**Tech Stack:** TypeScript 5.9, React 19, Fastify 5, SQLite/better-sqlite3, Drizzle schema declarations, `cron-parser`, Vitest, Testing Library, Vite, animal-island-ui 1.3.0.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Timezone is fixed to `Asia/Shanghai`; APIs reject any other timezone and UI copy displays Beijing time (`UTC+8`).
|
||||
- Cron is standard five-field syntax; seconds are unsupported and no minimum interval is enforced.
|
||||
- Misfire defaults to `skip`; catch-up runs at most once. Overlap defaults to `skip`; queueing is bounded to one occurrence.
|
||||
- Dynamic selectors resolve on every claim and support `any` or `all`; every run persists an immutable target snapshot and schedule version.
|
||||
- System reboot defaults to zero retries. All retry counts and intervals are bounded by contracts.
|
||||
- SMS recipients and content live only in the existing secret store. SQLite, logs, Audit, and HTTP reads expose references or redacted summaries only.
|
||||
- Deleting a schedule is soft deletion for configuration and never removes Jobs, Audit, or scheduled-run history.
|
||||
- Preserve existing dirty-worktree changes. Do not commit overlapping user-owned modifications; use test checkpoints instead.
|
||||
- UI uses cream solid surfaces, warm brown text, mint actions, thin warm borders, 14-16px panel radii, no floating card shadows, and restrained 120-200ms feedback.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Automation Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/contracts/src/automation.ts`
|
||||
- Create: `packages/contracts/src/automation.test.ts`
|
||||
- Modify: `packages/contracts/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ScheduledTask`, `ScheduledRun`, `CreateScheduledTaskRequest`, `UpdateScheduledTaskRequest`, `ScheduleTargetSelector`, `CronPreview`, policy and outcome constants.
|
||||
- Produces: `parseCreateScheduledTaskRequest(value: unknown)` and `parseUpdateScheduledTaskRequest(value: unknown)` with exact-key, length, enum, and numeric-bound validation.
|
||||
|
||||
- [ ] **Step 1: Write the failing contract tests**
|
||||
|
||||
```ts
|
||||
expect(parseCreateScheduledTaskRequest(validRestart)).toMatchObject({
|
||||
timezone: 'Asia/Shanghai', misfirePolicy: 'skip', overlapPolicy: 'skip'
|
||||
});
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...validRestart, timezone: 'UTC' })).toThrow();
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...validRestart, cronExpression: '* * * * * *' })).toThrow();
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...validSms, sms: { recipients: [], content: '' } })).toThrow();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run packages/contracts/src/automation.test.ts`
|
||||
|
||||
Expected: FAIL because `automation.ts` and parsers do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement strict contracts and parsers**
|
||||
|
||||
Use literal unions for `restart-service | reboot-system | send-sms`, `fixed | tags`, `skip | catch-up-once`, `skip | queue-once`, and the six approved run outcomes. Bound names to 120 characters, recipients to 50, fixed IDs/tags to 200, SMS content to 2,000 characters, retries to 10, and retry interval to 86,400 seconds.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run packages/contracts/src/automation.test.ts packages/contracts/src/index.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Scheduling Persistence
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/infrastructure/database/migrations.ts`
|
||||
- Modify: `apps/api/src/infrastructure/database/schema.ts`
|
||||
- Modify: `apps/api/src/infrastructure/database/database.test.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduled-task-repository.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduled-task-repository.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: contracts from Task 1.
|
||||
- Produces: `ScheduledTaskRepository.create/get/list/update/setEnabled/softDelete`, `claimOccurrence`, `startRun`, and `finishRun`.
|
||||
- Produces: migration 8 tables `scheduled_tasks` and `scheduled_runs`, with unique `(scheduled_task_id, schedule_version, due_at, trigger_source)` claim identity.
|
||||
|
||||
- [ ] **Step 1: Write failing migration and repository tests**
|
||||
|
||||
```ts
|
||||
expect(tableNames(db)).toContain('scheduled_tasks');
|
||||
expect(tableNames(db)).toContain('scheduled_runs');
|
||||
expect(repository.claimOccurrence(task.id, task.version, dueAt, 'scheduled', targets)).not.toBeNull();
|
||||
expect(repository.claimOccurrence(task.id, task.version, dueAt, 'scheduled', targets)).toBeNull();
|
||||
expect(repository.getRun(firstRun.id)?.targetSnapshot).toEqual(['instance-a', 'instance-b']);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/scheduled-task-repository.test.ts apps/api/src/infrastructure/database/database.test.ts -t "scheduled"`
|
||||
|
||||
Expected: FAIL because migration 8 and repository are absent.
|
||||
|
||||
- [ ] **Step 3: Add migration 8, Drizzle declarations, and repository**
|
||||
|
||||
Store selectors and policies as validated JSON, timestamps as UTC ISO strings, the fixed timezone marker, a nullable SMS secret reference, `deleted_at`, optimistic `version`, and immutable run snapshots. Wrap claim insert and overlap inspection in one better-sqlite3 transaction.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/scheduled-task-repository.test.ts apps/api/src/infrastructure/database/database.test.ts -t "scheduled|business tables"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Cron and Target Resolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
- Create: `apps/api/src/application/automation/schedule-time.ts`
|
||||
- Create: `apps/api/src/application/automation/schedule-time.test.ts`
|
||||
- Create: `apps/api/src/application/automation/target-resolver.ts`
|
||||
- Create: `apps/api/src/application/automation/target-resolver.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `previewCron(expression, count, from?)`, `nextOccurrence(expression, after)`, and `reconcileOccurrence(task, now)` using `Asia/Shanghai`.
|
||||
- Produces: `resolveTargets(db, selector): readonly ResolvedTarget[]` returning enabled instances with revisions and capability states.
|
||||
|
||||
- [ ] **Step 1: Add failing time and selector tests**
|
||||
|
||||
```ts
|
||||
expect(previewCron('0 9 * * *', 2, new Date('2026-07-30T00:30:00Z'))).toEqual([
|
||||
'2026-07-30T01:00:00.000Z', '2026-07-31T01:00:00.000Z'
|
||||
]);
|
||||
expect(resolveTargets(db, { mode: 'tags', match: 'all', tags: ['lab', 'east'] }).map(x => x.id)).toEqual(['a']);
|
||||
expect(resolveTargets(db, { mode: 'tags', match: 'any', tags: ['lab', 'east'] }).map(x => x.id)).toEqual(['a', 'b']);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/schedule-time.test.ts apps/api/src/application/automation/target-resolver.test.ts`
|
||||
|
||||
Expected: FAIL because services are absent.
|
||||
|
||||
- [ ] **Step 3: Install and implement**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/api add cron-parser`
|
||||
|
||||
Parse with explicit `tz: 'Asia/Shanghai'`, reject seconds fields before invoking the library, cap preview count at 10, and use parameterized SQL with `GROUP BY/HAVING` for tag matching.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/schedule-time.test.ts apps/api/src/application/automation/target-resolver.test.ts`
|
||||
|
||||
Expected: PASS, including Beijing day-boundary fixtures.
|
||||
|
||||
### Task 4: Schedule Service and SMS Secrets
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/application/automation/scheduled-task-service.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduled-task-service.test.ts`
|
||||
- Modify: `apps/api/src/infrastructure/secrets/secret-store.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ScheduledTaskService.create/update/pause/resume/remove/duplicate/preview/runNow/list/listRuns`.
|
||||
- Uses a single JSON SMS secret payload `{ recipients: string[]; content: string }` under a schedule-scoped `SecretKey`; reads never return the payload.
|
||||
|
||||
- [ ] **Step 1: Write failing lifecycle and redaction tests**
|
||||
|
||||
```ts
|
||||
const created = await service.create(actor, smsRequest);
|
||||
expect(created.sms).toEqual({ configured: true, recipientCount: 2 });
|
||||
expect(JSON.stringify(repository.get(created.id))).not.toContain('hello');
|
||||
expect(secretStore.getCalls).toHaveLength(0);
|
||||
expect((await service.update(actor, created.id, created.version, changedTargets)).version).toBe(2);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/scheduled-task-service.test.ts`
|
||||
|
||||
Expected: FAIL because the service is absent.
|
||||
|
||||
- [ ] **Step 3: Implement lifecycle and secret compensation**
|
||||
|
||||
Write the secret before the task transaction, compensate by deleting it when persistence fails, rotate and cleanup old references after successful sensitive edits, require optimistic versions, and preserve runs on soft delete.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/scheduled-task-service.test.ts`
|
||||
|
||||
Expected: PASS with no plaintext SMS values in serialized task or errors.
|
||||
|
||||
### Task 5: Scheduled Dispatch and Coordinator
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/application/automation/scheduled-operation-dispatcher.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduler-coordinator.ts`
|
||||
- Create: `apps/api/src/application/automation/scheduler-coordinator.test.ts`
|
||||
- Modify: `apps/api/src/application/messages/instance-message-service.ts`
|
||||
- Modify: `apps/api/src/application/messages/instance-message-service.test.ts`
|
||||
- Modify: `apps/api/src/application/operations/secure-operation-execution.ts`
|
||||
- Modify: `apps/api/src/application/operations/secure-operation-execution.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ScheduledOperationDispatcher.dispatch(run, task, targets)` and `SchedulerCoordinator.start/stop/tick`.
|
||||
- Extends message execution with a Job/Audit-recorded scheduled SMS method whose body digest is computed before dispatch and whose summaries contain only redacted fields.
|
||||
|
||||
- [ ] **Step 1: Write failing aggregate execution tests**
|
||||
|
||||
```ts
|
||||
expect(await dispatch(twoTargetsOneFailure)).toMatchObject({ outcome: 'partially-succeeded' });
|
||||
expect(await dispatch(noTargets)).toMatchObject({ outcome: 'no-targets' });
|
||||
expect(await dispatch(missingSmsSecret)).toMatchObject({ outcome: 'needs-attention' });
|
||||
expect(auditRows(db).every(row => !JSON.stringify(row).includes(phoneOrContent))).toBe(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts apps/api/src/application/automation/scheduler-coordinator.test.ts`
|
||||
|
||||
Expected: FAIL because dispatcher and coordinator are absent.
|
||||
|
||||
- [ ] **Step 3: Implement dispatch and coordinator lifecycle**
|
||||
|
||||
Dispatch each target independently, create correlated Jobs, aggregate outcomes, bound retries, default reboot retries to zero, reconcile at startup, claim before dispatch, skip or queue one overlap, and expose injected clock/timer dependencies for deterministic tests. Never persist interactive confirmation tokens.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/application/automation apps/api/src/application/messages/instance-message-service.test.ts apps/api/src/application/operations/secure-operation-execution.test.ts`
|
||||
|
||||
Expected: PASS for duplicates, misfire, overlap, retry, partial success, and redaction.
|
||||
|
||||
### Task 6: Automation HTTP API
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/interface/http/automation-routes.ts`
|
||||
- Create: `apps/api/src/interface/http/automation-routes.test.ts`
|
||||
- Modify: `apps/api/src/control-plane.ts`
|
||||
- Modify: `apps/api/src/control-plane.test.ts`
|
||||
- Modify: `apps/api/src/production-control-plane.ts`
|
||||
- Modify: `apps/api/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces authenticated `/api/v1/automation/schedules`, `/:id`, `/:id/state`, `/:id/run`, `/cron/preview`, and `/runs` routes.
|
||||
- Returns RFC 9457-style sanitized problems and ETags based on schedule version.
|
||||
|
||||
- [ ] **Step 1: Write failing route tests**
|
||||
|
||||
```ts
|
||||
expect((await app.inject({ method: 'POST', url: '/api/v1/automation/cron/preview', payload: { cronExpression: '0 9 * * *' } })).statusCode).toBe(200);
|
||||
expect(create.statusCode).toBe(201);
|
||||
expect(updateWithoutIfMatch.statusCode).toBe(428);
|
||||
expect(await getJson(create)).not.toHaveProperty('sms.content');
|
||||
expect(deleteResponse.statusCode).toBe(204);
|
||||
expect(runHistoryAfterDelete.items).toHaveLength(1);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/interface/http/automation-routes.test.ts`
|
||||
|
||||
Expected: FAIL with 404/unregistered routes.
|
||||
|
||||
- [ ] **Step 3: Implement routes and app lifecycle**
|
||||
|
||||
Use exact-key body parsing, authenticated actor IDs, request IDs, If-Match optimistic updates, bounded pagination, and coordinator close hooks. Register routes with the same auth scope as Jobs and Audit.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/api/src/interface/http/automation-routes.test.ts apps/api/src/control-plane.test.ts apps/api/src/production-control-plane.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 7: Automation Web Workspace
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/automation/automation-api-data-source.ts`
|
||||
- Create: `apps/web/src/automation/automation-api-data-source.test.ts`
|
||||
- Create: `apps/web/src/automation/automation-page.tsx`
|
||||
- Create: `apps/web/src/automation/automation-page.test.tsx`
|
||||
- Modify: `apps/web/src/jobs/jobs-page.tsx`
|
||||
- Modify: `apps/web/src/audit/audit-page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AutomationApiDataSource` CRUD/preview/run methods and `AutomationPage` with `schedules | runs | records` tabs.
|
||||
- Consumes existing `JobsPage`, `AuditPage`, instance summaries, and new automation contracts.
|
||||
|
||||
- [ ] **Step 1: Write failing UI behavior tests**
|
||||
|
||||
```tsx
|
||||
expect(screen.getByRole('tab', { name: 'Schedules' })).toHaveAttribute('aria-selected', 'true');
|
||||
await user.click(screen.getByRole('button', { name: 'Create schedule' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Create schedule' })).toBeVisible();
|
||||
await user.selectOptions(screen.getByLabelText('Target mode'), 'tags');
|
||||
expect(screen.getByLabelText('Tag matching')).toBeVisible();
|
||||
expect(screen.queryByLabelText('Timezone')).not.toBeInTheDocument();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/automation/automation-page.test.tsx apps/web/src/automation/automation-api-data-source.test.ts`
|
||||
|
||||
Expected: FAIL because Automation UI is absent.
|
||||
|
||||
- [ ] **Step 3: Implement dense list and progressive drawer**
|
||||
|
||||
Use native semantic controls where the library API does not fit; use animal-island-ui Button, Switch, Tag, Tabs, and Drawer where accessible. Include five-run Beijing preview, high-frequency restart warning plus confirmation, fixed/tag targets, SMS fields, optional window, policies, retries, row menu actions, empty/error/loading states, and redacted summaries.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/automation`
|
||||
|
||||
Expected: PASS for create/edit/pause/run/delete and keyboard focus behavior.
|
||||
|
||||
### Task 8: Shell and Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/app-shell.tsx`
|
||||
- Modify: `apps/web/src/app-shell.integration.test.tsx`
|
||||
- Modify: `apps/web/src/main.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Routes `/automation` to `AutomationPage`; aliases `/jobs` to Runs and `/audit` to Operation records.
|
||||
- Primary navigation contains Nodes, Automation, and Settings only.
|
||||
|
||||
- [ ] **Step 1: Write failing navigation tests**
|
||||
|
||||
```tsx
|
||||
expect(within(screen.getByRole('navigation', { name: 'Primary' })).getAllByRole('link')).toHaveLength(3);
|
||||
expect(screen.queryByRole('link', { name: 'Audit' })).not.toBeInTheDocument();
|
||||
history.pushState({}, '', '/jobs');
|
||||
expect(await screen.findByRole('tab', { name: 'Runs' })).toHaveAttribute('aria-selected', 'true');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/app-shell.integration.test.tsx`
|
||||
|
||||
Expected: FAIL against the old primary navigation.
|
||||
|
||||
- [ ] **Step 3: Implement compact shell and aliases**
|
||||
|
||||
Keep the header near 56px, preserve deep instance routes and settings subroutes, show connection state at the trailing edge, and use the existing icon component for navigation symbols.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/app-shell.integration.test.tsx`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 9: Fleet Workbench
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Modify: `apps/web/src/styles.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Preserves existing Fleet data-source and operation-client props.
|
||||
- Adds explicit `selectionMode` UI state; checkboxes exist only while it is true.
|
||||
|
||||
- [ ] **Step 1: Write failing Fleet interaction tests**
|
||||
|
||||
```tsx
|
||||
expect(screen.queryByRole('checkbox', { name: /select/i })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Batch select' }));
|
||||
expect(screen.getAllByRole('checkbox', { name: /select/i })).toHaveLength(visibleNodeCount + 1);
|
||||
await user.click(screen.getByRole('button', { name: 'Exit batch selection' }));
|
||||
expect(screen.queryByRole('checkbox', { name: /select/i })).not.toBeInTheDocument();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: FAIL because selection controls are permanently visible or no batch mode exists.
|
||||
|
||||
- [ ] **Step 3: Implement the warm dense Fleet layout**
|
||||
|
||||
Remove the persistent sidebar and decorative page title, combine counts/search/filter/sort/refresh/add/batch controls, use stable node panels with identity, endpoint/phone, CPU/memory/temperature, tags/recent SMS, restart/reboot actions, and responsive 3/2/1 columns. Define semantic tokens and reduced-motion/contrast/transparency media queries in `styles.css`.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `corepack pnpm vitest run apps/web/src/fleet/fleet-page.test.tsx apps/web/src/app-shell.integration.test.tsx`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 10: Integrated Verification and Visual QA
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/real-browser-e2e.mjs`
|
||||
- Test: relevant API/Web suites and production build output.
|
||||
|
||||
**Interfaces:**
|
||||
- Adds real-browser acceptance for Automation lifecycle, Fleet selection mode, route aliases, and responsive column/overflow checks.
|
||||
|
||||
- [ ] **Step 1: Extend browser assertions before UI fixes**
|
||||
|
||||
Add literal checks for three primary navigation items, Automation tabs, drawer creation flow, selection-checkbox absence/presence, and viewport screenshots at 390, 768, 1024, and 1440 pixels.
|
||||
|
||||
- [ ] **Step 2: Run focused and broad verification**
|
||||
|
||||
```powershell
|
||||
corepack pnpm vitest run packages/contracts/src/automation.test.ts apps/api/src/application/automation apps/api/src/interface/http/automation-routes.test.ts apps/web/src/automation apps/web/src/fleet/fleet-page.test.tsx apps/web/src/app-shell.integration.test.tsx
|
||||
corepack pnpm typecheck
|
||||
corepack pnpm lint
|
||||
corepack pnpm format:check
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
corepack pnpm test:e2e:browser
|
||||
```
|
||||
|
||||
Expected: all task-related checks PASS. Record known unrelated Windows baseline failures separately rather than suppressing them.
|
||||
|
||||
- [ ] **Step 3: Inspect visual output and correct defects**
|
||||
|
||||
Verify solid cream panels, no card shadows, correct 3/2/1 columns, no horizontal overflow, no text overlap, visible focus, 44px touch controls, and usable reduced-motion/high-contrast variants. Re-run affected tests after each correction.
|
||||
|
||||
- [ ] **Step 4: Run final regression**
|
||||
|
||||
Run: `corepack pnpm test:unit`
|
||||
|
||||
Expected: no new failures relative to the recorded 636-pass/24-fail Windows baseline, and every new or modified task-specific suite passes.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Warm Telemetry Node Card Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the segmented Fleet node card with the approved warm telemetry card while preserving navigation, selection, restart behavior, and responsive matrix geometry.
|
||||
|
||||
**Architecture:** Keep Fleet data and operation execution in `FleetPage`; only restructure the card DOM and add one local menu-open state. Let the existing final Fleet cascade remain the single styling owner, replacing its card selectors instead of appending a new theme block. Extend the existing component and real-browser tests to lock semantic order, menu behavior, and geometry.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, animal-island-ui, Vitest, Testing Library, Vite, Playwright-driven real Chrome script.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve the desktop left sidebar and the 1 / 2 / 2 / 3 / 4 card matrix at 390 / 768 / 1024 / 1440 / 1920 pixels.
|
||||
- Use the existing cream, warm-brown, mint, warning, and danger tokens; do not add photographic backgrounds, glass blur, diagonal stripes, red decorative borders, or card shadows.
|
||||
- Keep the current dashboard link, external origin link, confirmation prompts, and operation-client behavior.
|
||||
- The checkbox remains hidden outside batch mode and stays in the header control cluster when shown.
|
||||
- Do not add dependencies, do not refactor unrelated modules, and do not commit the existing dirty worktree.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Card Semantic Contract
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
- Test: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `FleetPage`, `FleetSnapshot`, existing accessible card names and operation client injection.
|
||||
- Produces: regression coverage for `.fleet-card-metadata`, `.fleet-card-hardware`, `.fleet-card-telemetry`, `.fleet-card-footer`, and the `实例操作 <name>` menu trigger.
|
||||
|
||||
- [ ] **Step 1: Replace the old section-order assertions with the approved scan order**
|
||||
|
||||
```tsx
|
||||
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.compareDocumentPosition(hardware) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(hardware.compareDocumentPosition(telemetry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(telemetry.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Assert restart controls are menu-only**
|
||||
|
||||
```tsx
|
||||
expect(within(card).queryByRole('button', { name: '重启服务 Alpha modem' })).toBeNull();
|
||||
const trigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(trigger);
|
||||
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused test and verify RED**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web test -- src/fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: FAIL because the new regions and menu trigger do not exist and the old buttons are still visible.
|
||||
|
||||
### Task 2: Build The Warm Telemetry Card Structure
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/fleet/fleet-page.tsx`
|
||||
- Test: `apps/web/src/fleet/fleet-page.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `runCardAction(id, kind)`, `messageStates`, `Progress`, `Tag`, and `Icon`.
|
||||
- Produces: `cardMenuId: string | undefined` state and accessible card action menus.
|
||||
|
||||
- [ ] **Step 1: Add local action-menu state**
|
||||
|
||||
```tsx
|
||||
const [cardMenuId, setCardMenuId] = useState<string>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the segmented card body**
|
||||
|
||||
Create the approved DOM order: `header`, `.fleet-card-metadata`, `.fleet-card-hardware`, `.fleet-card-telemetry`, and `.fleet-card-footer`. Move origin and both tag groups into metadata; keep phone and temperature in the hardware group; reuse the two existing `Progress` components in telemetry rows.
|
||||
|
||||
- [ ] **Step 3: Replace the bottom action bar with a header menu**
|
||||
|
||||
Use the existing `Icon name="more"`. The trigger must expose `aria-label`, `aria-haspopup="menu"`, and `aria-expanded`. Each menu item calls `setCardMenuId(undefined)` before `runCardAction` and preserves the current per-operation accessible labels.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and verify GREEN**
|
||||
|
||||
Run: `corepack pnpm --filter @multi-simadmin/web test -- src/fleet/fleet-page.test.tsx`
|
||||
|
||||
Expected: all Fleet tests pass with no warnings.
|
||||
|
||||
### Task 3: Replace Card Styling And Verify Browser Geometry
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/styles.css`
|
||||
- Modify: `scripts/real-browser-e2e.mjs`
|
||||
- Test: `scripts/real-browser-e2e.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: final Fleet cascade selectors and the new card class names from Task 2.
|
||||
- Produces: a shadowless warm telemetry card, compact floating menu, and geometry assertions for every supported viewport.
|
||||
|
||||
- [ ] **Step 1: Update browser selectors before CSS**
|
||||
|
||||
Add assertions that `.fleet-card-telemetry`, `.fleet-card-footer`, and `.fleet-card-menu-trigger` stay inside each card and viewport, and that desktop card columns remain 3 at 1440px and 4 at 1920px.
|
||||
|
||||
- [ ] **Step 2: Run browser E2E and verify RED**
|
||||
|
||||
Run: `corepack pnpm run test:e2e:browser`
|
||||
|
||||
Expected: FAIL because the new selectors are not yet styled to the approved geometry.
|
||||
|
||||
- [ ] **Step 3: Replace the final Fleet card selector group**
|
||||
|
||||
Use one hairline outer border, 12px radius, no card shadow, compact header controls, one muted hardware strip, uniform telemetry grid rows, one footer hairline, and a floating warm menu. Remove final-cascade ownership of `.fleet-card-facts`, `.fleet-card-resources`, `.fleet-card-sms`, and `.fleet-card-admin-actions`.
|
||||
|
||||
- [ ] **Step 4: Run focused and full web verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --filter @multi-simadmin/web test
|
||||
corepack pnpm --filter @multi-simadmin/web typecheck
|
||||
corepack pnpm lint
|
||||
corepack pnpm format:check
|
||||
corepack pnpm --filter @multi-simadmin/web build
|
||||
corepack pnpm run test:e2e:browser
|
||||
```
|
||||
|
||||
Expected: all commands exit 0; real Chrome passes at 390, 768, 1024, 1440, and 1920 pixels and writes updated review screenshots.
|
||||
|
||||
- [ ] **Step 5: Inspect 390px, 1440px, and 1920px screenshots**
|
||||
|
||||
Confirm the left sidebar remains left on desktop, outer gutters remain fluid, no text or menu overlaps, no card has the former bottom action bar, and telemetry values align consistently.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Compact Fleet Cards Design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Status:** Approved
|
||||
|
||||
## Intent
|
||||
|
||||
Increase the Fleet workbench's information density without moving the desktop overview and search out of the left sidebar. Remove the aggregate health strip because its fleet-wide CPU, memory, temperature, SMS, and online-rate values do not support the operator's primary workflow. Restore the upstream SimAdmin version to each instance card.
|
||||
|
||||
## Layout Contract
|
||||
|
||||
- The desktop overview and search remain in the left sidebar.
|
||||
- The results pane starts with the existing node-group tabs, followed by the result count, sort control, and card matrix.
|
||||
- The entire `节点资源健康` strip is removed, including online rate, average CPU, average memory, highest temperature, and SMS channel count.
|
||||
- The card matrix uses explicit viewport contracts: 1 column at 390px, 2 at 768px, 2 at 1024px, 4 at 1440px, and 5 at 1920px.
|
||||
- Cards continue to fill their grid tracks so the results pane has no artificial side gutters or fixed-width card gaps.
|
||||
|
||||
## Card Contract
|
||||
|
||||
- Keep the current warm, shadowless telemetry-card visual language: cream surface, warm hairline border, brown typography, mint status accents, and restrained interaction feedback.
|
||||
- Keep identity, status, batch selection, and the accessible action menu in the header.
|
||||
- Render the upstream version beneath the instance name as `SimAdmin <version>`.
|
||||
- When the upstream resource summary has no version, render `版本未知`; do not confuse the instance configuration revision with the SimAdmin version.
|
||||
- Keep per-instance origin, tags, capabilities, phone number, maximum temperature, CPU, memory, and latest SMS because these values support node-level diagnosis.
|
||||
- Preserve truncation and wrapping safeguards for narrow cards. The origin and long names may truncate with their full values available through existing accessible names or titles; hardware values must remain readable.
|
||||
|
||||
## Version Data Flow
|
||||
|
||||
The API resource endpoint already returns the upstream SimAdmin version. The observed local response from `/api/v1/instances/:id/resources` contains `version: "1.1.6"`. The API resource service, web data source, and Fleet view model already preserve this field. The defect is isolated to card rendering, which currently omits `row.version`.
|
||||
|
||||
The implementation therefore adds no new endpoint or fallback parser. It renders `row.version` in the identity area and tests the existing API-to-view-model contract to prevent another presentation-layer omission.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
- At 390px the page remains a single card column.
|
||||
- At 768px the stacked workspace uses two columns.
|
||||
- At 1024px the left sidebar remains visible and the results pane uses two columns to protect minimum readable card width.
|
||||
- At 1440px the results pane uses four compact columns.
|
||||
- At 1920px it uses five compact columns.
|
||||
- Menus, tags, telemetry rows, phone numbers, temperatures, and version labels must stay within card and viewport bounds.
|
||||
|
||||
## Testing
|
||||
|
||||
- Component tests assert the aggregate health region is absent.
|
||||
- Component and integration tests assert a resource version renders as `SimAdmin 1.1.6` and a missing version renders as `版本未知`.
|
||||
- Existing card navigation, menu, selection, restart, resource, and SMS tests remain green.
|
||||
- Real Chrome E2E asserts the 1/2/2/4/5 column contract at 390/768/1024/1440/1920 and checks version/card geometry.
|
||||
- Screenshots are reviewed at mobile and desktop widths for wrapping, overlap, excess whitespace, and accidental sidebar movement.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- No API contract or database changes.
|
||||
- No changes to instance-detail telemetry.
|
||||
- No removal of per-instance CPU, memory, temperature, phone, or SMS data.
|
||||
- No movement of overview or search controls into the results pane.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Fluid Wide Workbench Design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The final workbench cascade reintroduced `width: min(100%, 92rem)` and `max-width: 92rem` on `.app-layout`. A 1920px viewport therefore leaves approximately 224px of unused space on each side. The existing browser acceptance matrix stopped at 1440px, so it could not detect this regression.
|
||||
|
||||
## Selected Design
|
||||
|
||||
- Nodes, Automation, and Settings use one fluid top-level application container with no global maximum width.
|
||||
- Desktop outer gutters stay between 16px and 24px, including at 1920px and above.
|
||||
- The Fleet sidebar remains 17rem and sticky above 60rem; this correction does not move or restyle it.
|
||||
- Fleet retains one column at 390px, two at 768px and 1024px, three at 1440px, and uses four columns at 1920px.
|
||||
- Width limits on focused content such as forms, drawers, dialogs, message bubbles, and empty states remain unchanged.
|
||||
|
||||
## Cascade Ownership
|
||||
|
||||
The canonical `.app-layout` rule owns `width: 100%` and `max-width: none` for both normal and single-layout variants. Later workbench guards may adjust padding but must not redefine container width. Obsolete `92rem` and `106rem` top-level width declarations are removed so future visual changes cannot reactivate them through specificity or source order.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Real Chrome checks at 390px, 768px, 1024px, 1440px, and 1920px.
|
||||
- At 1920px, `.app-layout` has no more than 24px of empty space on either side for Nodes, Automation, and Settings.
|
||||
- Fleet has four columns at 1920px and no horizontal overflow.
|
||||
- Existing sidebar geometry and the 390/768/1024/1440 card-column expectations remain unchanged.
|
||||
- Focused content width limits remain intact.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Komari-Inspired Fleet Workbench Design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Status:** Approved, card structure revised
|
||||
|
||||
## Intent
|
||||
|
||||
Use the reference monitor's information density and scan order without copying its photographic background, glass treatment, striped cards, red decoration, fixed footer, or broken mobile overflow. SimAdmin remains a warm operations workbench: cream surfaces, brown typography, mint status accents, restrained elevation, and direct operational controls.
|
||||
|
||||
## Layout Contract
|
||||
|
||||
- The desktop Fleet overview and search remain in the fixed left sidebar. They must not move into the center content pane.
|
||||
- The top-level application remains fluid with 16px to 24px outer gutters and no global maximum width.
|
||||
- The existing responsive card matrix remains 1 / 2 / 2 / 3 / 4 columns at 390 / 768 / 1024 / 1440 / 1920 pixels.
|
||||
- At 960px and below, the sidebar stacks above results and remains collapsible without horizontal overflow.
|
||||
|
||||
## Main Results Pane
|
||||
|
||||
- A compact five-metric resource health strip appears above the tag groups: online rate, average CPU, average memory, highest temperature, and readable SMS channels.
|
||||
- The strip reports only values already available in the Fleet snapshot and message-summary state. Missing measurements render as `--` rather than invented zero values.
|
||||
- A horizontal tag group row provides `全部节点` plus available instance tags. It updates the existing tag filter and does not duplicate search ownership.
|
||||
- The result count, sort control, and batch controls retain their current behavior.
|
||||
|
||||
## Node Card Structure
|
||||
|
||||
The node card is a warm SimAdmin adaptation of the reference monitor card. It borrows the reference's stable top-to-bottom telemetry rhythm, not its striped decoration, glass surface, red frame, photographic background, or footer treatment.
|
||||
|
||||
1. A compact header owns identity and controls: status-aware avatar, node name, dashboard affordance, status tag, optional batch checkbox, and a single icon action-menu trigger.
|
||||
2. Origin, instance tags, and capability tags form one compact metadata flow immediately below the header. They must not render as separate card sections.
|
||||
3. Phone number and maximum temperature share one quiet hardware facts strip. Values align to the outer edges and missing values remain explicit.
|
||||
4. CPU and memory render as uniform, full-width telemetry rows. Every row uses the same label column, progress track, and right-aligned tabular value.
|
||||
5. Latest SMS becomes a compact card footer: message state/direction and number on the left, timestamp on the right. Empty and unavailable states occupy the same geometry.
|
||||
6. Service restart and system reboot move into the header action menu. The destructive system reboot item uses semantic danger color, while the menu trigger remains visually secondary.
|
||||
|
||||
The batch checkbox is hidden outside batch-selection mode. In selection mode it remains in the header control cluster beside the status and action menu, so identity geometry does not shift unpredictably.
|
||||
|
||||
## Interaction Contract
|
||||
|
||||
- The card title remains the primary link to the instance dashboard.
|
||||
- The origin remains a separate external link and cannot activate card navigation.
|
||||
- The action trigger is a 44px icon button with an accessible name, `aria-haspopup="menu"`, and accurate `aria-expanded` state.
|
||||
- Menu commands keep the existing confirmation and operation-client behavior. Choosing a command closes the menu before the confirmation flow starts.
|
||||
- Operation progress or errors render below the SMS footer without restoring the old full-width action bar.
|
||||
- Keyboard focus is always visible. Reduced-motion removes card movement and progress animation.
|
||||
|
||||
## Visual System
|
||||
|
||||
- Cards use a flat cream surface, one warm hairline border, a 12px radius, and no decorative glow or default drop shadow.
|
||||
- Hover changes border and surface tone with a subtle `translateY(-1px)` response; selected cards use a mint inset ring.
|
||||
- Progress tracks are quiet warm-neutral rails with solid mint fills. No animated diagonal stripes are introduced.
|
||||
- Section hierarchy comes from spacing, weight, a single hardware strip, one footer hairline, and tabular numerals rather than stacked dividers or nested cards.
|
||||
- The card action menu may use a restrained warm elevation because it floats above the card; the card itself remains shadowless.
|
||||
- Motion stays between 120ms and 220ms and is removed under `prefers-reduced-motion`.
|
||||
|
||||
## Cascade Ownership
|
||||
|
||||
Fleet workbench refinements live in the existing final approved cascade section at the end of `apps/web/src/styles.css`. New Fleet selectors must not be appended as another theme experiment. Earlier legacy rules may remain for unrelated screens, but the final section is the single owner of Fleet layout, health strip, grouping row, card, resource, SMS, and selection geometry.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Component tests verify all five health metrics, tag group filtering, card content order, checkbox placement semantics, and menu-only restart controls.
|
||||
- Real Chrome verifies the left/stacked sidebar relationship and 1 / 2 / 2 / 3 / 4 card matrix at all five viewport widths.
|
||||
- Chrome geometry verifies the health strip, tag groups, resource rows, card menu trigger, and cards stay within the viewport.
|
||||
- Screenshots are reviewed at 390px, 1440px, and 1920px.
|
||||
@@ -0,0 +1,259 @@
|
||||
# SimAdmin Warm Operations Workbench Design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Status:** Approved design
|
||||
|
||||
**Primary goal:** Replace the conflicting glass-and-game UI with a coherent warm operations workbench, and turn the current read-only Jobs surface into a durable automation center for scheduled restarts and SMS delivery.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This design covers two connected changes:
|
||||
|
||||
1. Rework the application shell and Fleet page into a compact, warm, information-dense operations interface.
|
||||
2. Replace the current top-level Jobs and Audit destinations with an Automation center containing schedules, execution history, and lower-priority operation records.
|
||||
|
||||
The existing instance routes, SSE refresh behavior, settings, safe operation catalog, Jobs history, Audit history, and SimAdmin feature modules remain in place unless this document explicitly changes their presentation or integration.
|
||||
|
||||
## 2. Reference Direction
|
||||
|
||||
The selected direction is **Warm Operations Workbench**.
|
||||
|
||||
- Animal Island supplies the cream surfaces, warm brown text, mint accent, Nunito/Noto Sans SC typography, friendly geometry, and restrained tactile primary actions.
|
||||
- Apple supplies hierarchy, immediate press feedback, restraint, sparse elevation, predictable control placement, and accessibility fallbacks.
|
||||
- Notion supplies warm workspace organization, thin borders, compact content panels, and clear separation between primary work and supporting records.
|
||||
- Linear supplies the scanning model for dense operational information, especially status rows, resource values, filters, and history views.
|
||||
|
||||
Apple's cold white glass material is not used. Ribbon titles, dotted wallpaper, deep card shadows, decorative gradients, and permanently visible selection controls are excluded from operational surfaces.
|
||||
|
||||
## 3. Information Architecture
|
||||
|
||||
The top-level navigation becomes:
|
||||
|
||||
- **Nodes** (`/fleet` and instance routes)
|
||||
- **Automation** (`/automation`)
|
||||
- **Settings** (`/settings/instances` and `/settings/system`)
|
||||
|
||||
The existing Jobs and Audit routes remain compatible through redirects or route aliases, but are no longer separate primary navigation items.
|
||||
|
||||
Automation contains three views:
|
||||
|
||||
1. **Schedules**: create and manage Cron-based tasks.
|
||||
2. **Runs**: present existing Jobs data and schedule-run aggregation.
|
||||
3. **Operation records**: present existing Audit data for security and troubleshooting. This view is intentionally secondary and read-only.
|
||||
|
||||
## 4. Visual System
|
||||
|
||||
### 4.1 Global shell
|
||||
|
||||
- Use a compact header of approximately 56px.
|
||||
- Place product identity on the left, three primary navigation items in the main navigation area, and connection state on the right.
|
||||
- Use solid warm surfaces. Translucency may be used only for a sticky overlay that visibly floats over scrolling content; it is not the default material.
|
||||
- Remove oversized page headers and decorative English kickers.
|
||||
- Use plain semantic headings rather than Animal Island ribbon titles on dense operational pages.
|
||||
- Keep top-level page gutters between 16px and 24px at every desktop width. The application shell remains fluid and must not introduce centered outer whitespace through a global `max-width`.
|
||||
- Content-specific limits remain valid for forms, dialogs, drawers, message bubbles, and empty states where long line lengths would reduce usability; these limits must never constrain the top-level Nodes, Automation, or Settings workspaces.
|
||||
|
||||
### 4.2 Tokens
|
||||
|
||||
- Background: warm cream derived from Animal Island, not cool gray.
|
||||
- Content surface: slightly lighter cream or white-cream.
|
||||
- Primary text: warm brown; never pure black.
|
||||
- Secondary text: muted warm brown with WCAG-compliant contrast.
|
||||
- Primary action and selection: mint/teal.
|
||||
- Warning: warm amber.
|
||||
- Destructive and system reboot: semantic red, limited to labels, borders, icons, and confirmation surfaces.
|
||||
- Card radius: 14px to 16px.
|
||||
- Control radius: established Animal Island control grammar, including pills where appropriate.
|
||||
- Default cards have no drop shadow. Hierarchy comes from solid surface changes and 1px warm hairlines.
|
||||
- Selected cards use a stable 2px mint outline and a very light mint fill without changing layout dimensions.
|
||||
|
||||
### 4.3 Motion and accessibility
|
||||
|
||||
- Interaction transitions last 120ms to 200ms.
|
||||
- Pointer-down and active states provide immediate scale or translation feedback.
|
||||
- Motion communicates selection, expansion, state updates, and surface arrival only.
|
||||
- All controls support keyboard navigation and visible focus.
|
||||
- `prefers-reduced-motion`, `prefers-reduced-transparency`, and `prefers-contrast` are respected.
|
||||
- Touch targets are at least 44px on touch layouts.
|
||||
|
||||
## 5. Fleet Page
|
||||
|
||||
### 5.1 Layout
|
||||
|
||||
- Preserve the persistent Fleet sidebar on desktop as the primary node directory. It contains the node summary, search, status facets, advanced filters, and active-filter state.
|
||||
- Keep sorting, refresh, add-instance, batch-mode entry, and node cards in the main results pane so filtering and results remain visually distinct.
|
||||
- At widths above 60rem, use a sticky two-column workspace with a 17rem sidebar that can collapse to a compact 4.5rem rail. At 60rem and below, stack the sidebar above the results and let the existing disclosure control collapse its contents.
|
||||
- Render nodes in four columns at 112rem and above, three columns on wide desktop, two columns on medium layouts, and one column on mobile, measured within the available results pane.
|
||||
- Keep panel dimensions stable so status changes and progress updates do not shift the grid.
|
||||
|
||||
### 5.2 Node panel
|
||||
|
||||
Each node panel contains, in order:
|
||||
|
||||
1. Identity: avatar, name, status, and detail link.
|
||||
2. Endpoint and phone-number summary.
|
||||
3. CPU, memory, and maximum temperature in one compact resource row.
|
||||
4. Tags and recent SMS summary when available.
|
||||
5. Service restart and system reboot actions.
|
||||
|
||||
Cards use a cream solid surface, warm hairline border, 14px to 16px radius, and no floating shadow. Hover does not lift the whole panel. Action buttons may use subtle press feedback.
|
||||
|
||||
Selection controls are hidden during normal browsing. A toolbar command enters batch-selection mode, after which checkboxes appear in a consistent leading position. Leaving selection mode removes them. This prevents selection chrome from permanently competing with node identity.
|
||||
|
||||
## 6. Automation Center
|
||||
|
||||
### 6.1 Schedule list
|
||||
|
||||
Schedules use a dense list or table rather than repeated cards. Each row shows:
|
||||
|
||||
- name;
|
||||
- operation type;
|
||||
- fixed-instance or tag-selector target summary;
|
||||
- Cron expression;
|
||||
- next execution time in Beijing time;
|
||||
- latest result;
|
||||
- enabled state;
|
||||
- row menu for edit, run now, duplicate, pause/resume, and delete.
|
||||
|
||||
Deleting a schedule stops future runs but never removes immutable execution or operation history.
|
||||
|
||||
### 6.2 Schedule editor
|
||||
|
||||
The editor opens in a right-side drawer and uses progressive sections:
|
||||
|
||||
1. Name.
|
||||
2. Operation: restart SimAdmin service, reboot device system, or send SMS.
|
||||
3. Targets: multiple fixed instances or dynamic tag matching.
|
||||
4. SMS configuration when applicable: one or more recipient numbers and message content.
|
||||
5. Standard five-field Cron expression with future-five-run preview.
|
||||
6. Optional effective start and end times.
|
||||
7. Misfire behavior: skip or catch up once; default is skip.
|
||||
8. Overlap behavior: skip or queue once; default is skip.
|
||||
9. Bounded retry count and interval. System reboot defaults to no automatic retries.
|
||||
10. Final target/risk summary and confirmation.
|
||||
|
||||
The only supported timezone is `Asia/Shanghai`. The UI does not expose a timezone selector. Cron parsing, previews, misfire reconciliation, run timestamps, and operation-record timestamps are displayed as Beijing time (`UTC+8`). APIs reject alternate timezone input.
|
||||
|
||||
Cron frequency is not restricted. High-frequency restart schedules display a prominent warning, future-run preview, and an additional confirmation, but the product does not block saving or execution.
|
||||
|
||||
### 6.3 Target matching
|
||||
|
||||
- Fixed mode stores a set of instance IDs.
|
||||
- Dynamic mode stores a tag expression and resolves it at every trigger.
|
||||
- Dynamic matching supports choosing whether all selected tags or any selected tag must match.
|
||||
- The resolved target list is persisted as an immutable run snapshot.
|
||||
- Instances added to a matching tag after schedule creation are included in later runs.
|
||||
- A run with no matching targets receives the `no-targets` outcome and is not treated as an infrastructure failure.
|
||||
|
||||
## 7. Persistence Model
|
||||
|
||||
### 7.1 Scheduled tasks
|
||||
|
||||
`scheduled_tasks` stores non-secret configuration:
|
||||
|
||||
- identifier, name, operation type, enabled state, version;
|
||||
- five-field Cron expression;
|
||||
- fixed `Asia/Shanghai` timezone marker;
|
||||
- target mode and target-selector JSON;
|
||||
- effective start/end;
|
||||
- misfire, overlap, and retry policy;
|
||||
- next due time, last evaluated time;
|
||||
- creator/updater identity and timestamps;
|
||||
- a reference to secret SMS payload data when needed.
|
||||
|
||||
### 7.2 Scheduled runs
|
||||
|
||||
`scheduled_runs` stores:
|
||||
|
||||
- schedule ID and immutable schedule version;
|
||||
- due, claimed, started, and finished times;
|
||||
- resolved target snapshot;
|
||||
- aggregate outcome and skip/failure reason;
|
||||
- correlated Job identifiers;
|
||||
- manual or scheduled trigger source;
|
||||
- attempt and retry metadata.
|
||||
|
||||
SMS recipient numbers and message content reuse the existing secret-storage infrastructure. The SQLite schedule row stores only a reference. Audit and application logs never render message content and continue masking phone numbers.
|
||||
|
||||
## 8. Scheduler and Execution
|
||||
|
||||
- Use a maintained Cron parser with timezone support; do not implement Cron grammar manually.
|
||||
- On API startup, load enabled schedules and reconcile due times.
|
||||
- Apply each schedule's misfire policy: skip or execute one catch-up run. Never replay every missed occurrence.
|
||||
- Claim a due occurrence transactionally using schedule ID, version, and due time so restarts or concurrent workers cannot execute it twice.
|
||||
- Resolve targets at claim time and persist the target snapshot before dispatch.
|
||||
- Validate that each instance is enabled, has the required capability, and has a current configuration revision.
|
||||
- Execute each target independently. One target failure does not block other targets.
|
||||
- Aggregate individual Jobs into one scheduled run.
|
||||
|
||||
Schedule creation performs durable authorization for the exact schedule definition. Runtime execution validates the stored schedule version and operation constraints. Short-lived interactive confirmation tokens are never persisted or reused. Editing the operation, targets, Cron expression, or SMS payload creates a new version and requires confirmation again.
|
||||
|
||||
Restart operations continue through the safe operation path. Scheduled SMS execution must gain equivalent Job correlation, request-body digesting, redaction, and Audit coverage rather than calling the existing direct message route without durable history.
|
||||
|
||||
## 9. Outcomes and Error Handling
|
||||
|
||||
Run outcomes are:
|
||||
|
||||
- `succeeded`: every dispatch succeeded;
|
||||
- `partially-succeeded`: at least one dispatch succeeded and at least one failed;
|
||||
- `failed`: all attempted dispatches failed;
|
||||
- `skipped`: missed window, overlap, disabled schedule, or an explicit scheduler skip;
|
||||
- `no-targets`: the current selector matched no enabled instances;
|
||||
- `needs-attention`: missing SMS secret, invalid schedule version, or removed capability.
|
||||
|
||||
Offline or authentication-failed instances fail independently and follow the task's retry policy. System reboot has zero retries by default. Consecutive failures produce one persistent summary in the Automation page rather than repeated global notifications.
|
||||
|
||||
Invalid Cron syntax blocks save. Backend validation remains authoritative even when the UI preview succeeds. Missing secret data disables execution and surfaces `needs-attention`; it never substitutes an empty SMS body.
|
||||
|
||||
## 10. API Surface
|
||||
|
||||
The Automation UI requires authenticated endpoints for:
|
||||
|
||||
- list/get/create/update schedules;
|
||||
- pause/resume and delete schedules;
|
||||
- preview Cron occurrences in Beijing time;
|
||||
- run a schedule immediately;
|
||||
- list/get scheduled runs and their correlated Jobs.
|
||||
|
||||
Requests and responses use strict contracts, bounded arrays/strings, exact-key validation where used elsewhere in the project, redacted problem details, and optimistic version checks for edits.
|
||||
|
||||
## 11. Testing and Acceptance
|
||||
|
||||
### 11.1 Automated tests
|
||||
|
||||
- Cron parsing, Beijing-time boundaries, and future occurrence calculation.
|
||||
- Startup reconciliation with skip and one-catch-up misfire policies.
|
||||
- Transactional claim and duplicate prevention.
|
||||
- Fixed multi-instance and dynamic any/all tag matching.
|
||||
- Overlap skip and queue-once behavior.
|
||||
- Bounded retries and no-retry system reboot default.
|
||||
- Service restart, system reboot, and SMS execution.
|
||||
- Per-target partial success and aggregate outcomes.
|
||||
- Schedule version invalidation after sensitive edits.
|
||||
- SMS secret persistence and complete log/Audit redaction.
|
||||
- Schedule CRUD, pause/resume, duplicate, run-now, and immutable history.
|
||||
- Existing Fleet, Jobs, Audit, settings, SSE, and instance-route regressions.
|
||||
|
||||
### 11.2 Browser and visual acceptance
|
||||
|
||||
- Real-browser flow for creating, editing, pausing, running, and deleting schedules.
|
||||
- Keyboard-only completion of Fleet selection and schedule creation.
|
||||
- Screenshots at 390px, 768px, 1024px, 1440px, and 1920px.
|
||||
- No horizontal overflow or overlapping text.
|
||||
- Fleet renders one, two, three, or four columns at the intended widths.
|
||||
- At 1024px and 1440px, the Fleet summary and search remain in a left-hand sidebar beside the results pane. At 390px and 768px, the sidebar becomes a top disclosure without losing its heading or controls.
|
||||
- At 1920px, the Nodes, Automation, and Settings top-level workspaces keep 16px to 24px outer gutters rather than a centered fixed-width shell.
|
||||
- Selection controls appear only in batch mode.
|
||||
- Reduced-motion and increased-contrast states remain usable.
|
||||
- Existing instance actions and deep links remain functional.
|
||||
|
||||
## 12. Non-Goals
|
||||
|
||||
- User-selectable timezones.
|
||||
- Second-level Cron expressions.
|
||||
- Enforcing a minimum schedule interval.
|
||||
- Replaying every occurrence missed during downtime.
|
||||
- A general workflow builder with conditional branches.
|
||||
- Removing immutable Audit data from the backend.
|
||||
- Replacing Animal Island UI wholesale with another component library.
|
||||
+1
-1
@@ -4,7 +4,7 @@ import tseslint from 'typescript-eslint';
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['node_modules/**', 'coverage/**', 'packages/**/test/fixtures/**'],
|
||||
ignores: ['node_modules/**', '**/dist/**', 'coverage/**', 'packages/**/test/fixtures/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseCreateScheduledTaskRequest, parseUpdateScheduledTaskRequest } from './automation.js';
|
||||
|
||||
const restartRequest = {
|
||||
name: 'Morning restart',
|
||||
operationType: 'restart-service',
|
||||
cronExpression: '0 9 * * *',
|
||||
targetSelector: { mode: 'fixed', instanceIds: ['instance-a', 'instance-b'] },
|
||||
};
|
||||
|
||||
describe('automation contracts', () => {
|
||||
it('applies the approved timezone and safe default policies', () => {
|
||||
expect(parseCreateScheduledTaskRequest(restartRequest)).toMatchObject({
|
||||
timezone: 'Asia/Shanghai',
|
||||
misfirePolicy: 'skip',
|
||||
overlapPolicy: 'skip',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects alternate timezones, seconds fields, and unknown keys', () => {
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, timezone: 'UTC' })).toThrow(
|
||||
/timezone/i,
|
||||
);
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({ ...restartRequest, cronExpression: '* * * * * *' }),
|
||||
).toThrow(/five-field/i);
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, surprise: true })).toThrow(
|
||||
/unknown/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('validates fixed and dynamic target selectors without silently dropping values', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
targetSelector: { mode: 'tags', match: 'all', tags: ['lab', 'east'] },
|
||||
}).targetSelector,
|
||||
).toEqual({ mode: 'tags', match: 'all', tags: ['lab', 'east'] });
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
targetSelector: { mode: 'fixed', instanceIds: [] },
|
||||
}),
|
||||
).toThrow(/instance/i);
|
||||
});
|
||||
|
||||
it('requires bounded recipients and content only for SMS schedules', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'send-sms',
|
||||
sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' },
|
||||
}).sms,
|
||||
).toEqual({ recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' });
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'send-sms',
|
||||
sms: { recipients: [], content: '' },
|
||||
}),
|
||||
).toThrow(/sms/i);
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'restart-service',
|
||||
sms: { recipients: ['13800138000'], content: 'not allowed' },
|
||||
}),
|
||||
).toThrow(/sms/i);
|
||||
});
|
||||
|
||||
it('defaults system reboot to no retry and accepts bounded per-task policies', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'reboot-system',
|
||||
misfirePolicy: 'catch-up-once',
|
||||
overlapPolicy: 'queue-once',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
misfirePolicy: 'catch-up-once',
|
||||
overlapPolicy: 'queue-once',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an optimistic version and at least one editable field for updates', () => {
|
||||
expect(parseUpdateScheduledTaskRequest({ version: 2, name: 'Updated name' })).toEqual({
|
||||
version: 2,
|
||||
name: 'Updated name',
|
||||
});
|
||||
expect(() => parseUpdateScheduledTaskRequest({ version: 2 })).toThrow(/field/i);
|
||||
expect(() => parseUpdateScheduledTaskRequest({ version: 0, name: 'bad' })).toThrow(/version/i);
|
||||
});
|
||||
|
||||
it('rejects non-boolean enabled values instead of coercing them to false', () => {
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, enabled: 'false' })).toThrow(
|
||||
/enabled/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { PageEnvelope, PageQuery } from './instances.js';
|
||||
|
||||
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
||||
export const SCHEDULED_OPERATION_TYPES = ['restart-service', 'reboot-system', 'send-sms'] as const;
|
||||
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
|
||||
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
|
||||
export const SCHEDULED_RUN_OUTCOMES = [
|
||||
'succeeded',
|
||||
'partially-succeeded',
|
||||
'failed',
|
||||
'skipped',
|
||||
'no-targets',
|
||||
'needs-attention',
|
||||
] as const;
|
||||
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
|
||||
|
||||
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
|
||||
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
|
||||
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
|
||||
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
|
||||
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
|
||||
|
||||
export type ScheduleTargetSelector =
|
||||
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
||||
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] };
|
||||
|
||||
export interface ScheduleRetryPolicy {
|
||||
readonly maxRetries: number;
|
||||
readonly intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface ScheduledSmsInput {
|
||||
readonly recipients: readonly string[];
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface ScheduledSmsSummary {
|
||||
readonly configured: boolean;
|
||||
readonly recipientCount: number;
|
||||
}
|
||||
|
||||
export interface CreateScheduledTaskRequest {
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy: ScheduleRetryPolicy;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScheduledTaskRequest {
|
||||
readonly version: number;
|
||||
readonly name?: string;
|
||||
readonly operationType?: ScheduledOperationType;
|
||||
readonly cronExpression?: string;
|
||||
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector?: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput | null;
|
||||
readonly effectiveStartAt?: string | null;
|
||||
readonly effectiveEndAt?: string | null;
|
||||
readonly misfirePolicy?: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy?: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy?: ScheduleRetryPolicy;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsSummary;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy: ScheduleRetryPolicy;
|
||||
readonly enabled: boolean;
|
||||
readonly version: number;
|
||||
readonly nextDueAt?: string;
|
||||
readonly lastEvaluatedAt?: string;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScheduledRun {
|
||||
readonly id: string;
|
||||
readonly scheduledTaskId: string;
|
||||
readonly scheduleVersion: number;
|
||||
readonly taskName: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly dueAt: string;
|
||||
readonly claimedAt: string;
|
||||
readonly startedAt?: string;
|
||||
readonly finishedAt?: string;
|
||||
readonly targetSnapshot: readonly string[];
|
||||
readonly outcome?: ScheduledRunOutcome;
|
||||
readonly reason?: string;
|
||||
readonly jobIds: readonly string[];
|
||||
readonly triggerSource: ScheduledRunTriggerSource;
|
||||
readonly attempt: number;
|
||||
}
|
||||
|
||||
export type ScheduledTaskPageQuery = PageQuery<'name' | 'nextDueAt' | 'updatedAt'> & {
|
||||
readonly enabled?: boolean;
|
||||
readonly operationType?: ScheduledOperationType;
|
||||
};
|
||||
export type ScheduledTaskPage = PageEnvelope<ScheduledTask>;
|
||||
export type ScheduledRunPageQuery = PageQuery<'dueAt' | 'finishedAt'> & {
|
||||
readonly scheduledTaskId?: string;
|
||||
readonly outcome?: ScheduledRunOutcome;
|
||||
};
|
||||
export type ScheduledRunPage = PageEnvelope<ScheduledRun>;
|
||||
|
||||
export interface CronPreview {
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly occurrences: readonly string[];
|
||||
}
|
||||
|
||||
const CREATE_KEYS = new Set([
|
||||
'name',
|
||||
'operationType',
|
||||
'cronExpression',
|
||||
'timezone',
|
||||
'targetSelector',
|
||||
'sms',
|
||||
'effectiveStartAt',
|
||||
'effectiveEndAt',
|
||||
'misfirePolicy',
|
||||
'overlapPolicy',
|
||||
'retryPolicy',
|
||||
'enabled',
|
||||
]);
|
||||
const UPDATE_KEYS = new Set([...CREATE_KEYS, 'version']);
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
throw new TypeError(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
||||
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unknown) throw new TypeError(`Unknown field: ${unknown}`);
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string, maximum: number): string {
|
||||
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
|
||||
const clean = value.trim();
|
||||
if (clean.length === 0 || clean.length > maximum)
|
||||
throw new TypeError(`${label} must contain 1-${maximum} characters`);
|
||||
return clean;
|
||||
}
|
||||
|
||||
function member<T extends string>(value: unknown, values: readonly T[], label: string): T {
|
||||
if (typeof value !== 'string' || !values.includes(value as T))
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string, maximum: number): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > maximum)
|
||||
throw new TypeError(`${label} must contain 1-${maximum} values`);
|
||||
const result = value.map((item) => string(item, label, 120));
|
||||
if (new Set(result).size !== result.length) throw new TypeError(`${label} must be unique`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function cronExpression(value: unknown): string {
|
||||
const expression = string(value, 'cronExpression', 120).replace(/\s+/g, ' ');
|
||||
if (expression.split(' ').length !== 5)
|
||||
throw new TypeError('cronExpression must use standard five-field syntax');
|
||||
return expression;
|
||||
}
|
||||
|
||||
function isoDate(value: unknown, label: string): string {
|
||||
const raw = string(value, label, 64);
|
||||
const time = Date.parse(raw);
|
||||
if (!Number.isFinite(time)) throw new TypeError(`${label} must be an ISO timestamp`);
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function targetSelector(value: unknown): ScheduleTargetSelector {
|
||||
const source = object(value, 'targetSelector');
|
||||
if (source.mode === 'fixed') {
|
||||
exactKeys(source, new Set(['mode', 'instanceIds']));
|
||||
return { mode: 'fixed', instanceIds: strings(source.instanceIds, 'instanceIds', 200) };
|
||||
}
|
||||
if (source.mode === 'tags') {
|
||||
exactKeys(source, new Set(['mode', 'match', 'tags']));
|
||||
return {
|
||||
mode: 'tags',
|
||||
match: member(source.match, ['any', 'all'] as const, 'tag match'),
|
||||
tags: strings(source.tags, 'tags', 200),
|
||||
};
|
||||
}
|
||||
throw new TypeError('targetSelector mode is invalid');
|
||||
}
|
||||
|
||||
function sms(value: unknown): ScheduledSmsInput {
|
||||
const source = object(value, 'sms');
|
||||
exactKeys(source, new Set(['recipients', 'content']));
|
||||
return {
|
||||
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
|
||||
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
|
||||
return recipient;
|
||||
}),
|
||||
content: string(source.content, 'SMS content', 2_000),
|
||||
};
|
||||
}
|
||||
|
||||
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
|
||||
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
|
||||
const source = object(value, 'retryPolicy');
|
||||
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
|
||||
const maxRetries = source.maxRetries;
|
||||
const intervalSeconds = source.intervalSeconds;
|
||||
if (
|
||||
!Number.isSafeInteger(maxRetries) ||
|
||||
(maxRetries as number) < 0 ||
|
||||
(maxRetries as number) > 10
|
||||
)
|
||||
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
|
||||
if (
|
||||
!Number.isSafeInteger(intervalSeconds) ||
|
||||
(intervalSeconds as number) < 1 ||
|
||||
(intervalSeconds as number) > 86_400
|
||||
)
|
||||
throw new TypeError('retryPolicy.intervalSeconds must be between 1 and 86400');
|
||||
if (operationType === 'reboot-system' && (maxRetries as number) > 0)
|
||||
throw new TypeError('System reboot automatic retries are not supported');
|
||||
return { maxRetries: maxRetries as number, intervalSeconds: intervalSeconds as number };
|
||||
}
|
||||
|
||||
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
|
||||
const source = object(value, 'schedule');
|
||||
exactKeys(source, CREATE_KEYS);
|
||||
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
|
||||
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
|
||||
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
|
||||
if (operationType === 'send-sms' && !smsValue)
|
||||
throw new TypeError('SMS configuration is required');
|
||||
if (operationType !== 'send-sms' && smsValue)
|
||||
throw new TypeError('SMS is only valid for send-sms');
|
||||
const effectiveStartAt =
|
||||
source.effectiveStartAt === undefined
|
||||
? undefined
|
||||
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
|
||||
const effectiveEndAt =
|
||||
source.effectiveEndAt === undefined
|
||||
? undefined
|
||||
: isoDate(source.effectiveEndAt, 'effectiveEndAt');
|
||||
if (effectiveStartAt && effectiveEndAt && effectiveEndAt <= effectiveStartAt)
|
||||
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
||||
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
||||
throw new TypeError('enabled must be boolean');
|
||||
return {
|
||||
name: string(source.name, 'name', 120),
|
||||
operationType,
|
||||
cronExpression: cronExpression(source.cronExpression),
|
||||
timezone: AUTOMATION_TIMEZONE,
|
||||
targetSelector: targetSelector(source.targetSelector),
|
||||
...(smsValue ? { sms: smsValue } : {}),
|
||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||
misfirePolicy:
|
||||
source.misfirePolicy === undefined
|
||||
? 'skip'
|
||||
: member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy'),
|
||||
overlapPolicy:
|
||||
source.overlapPolicy === undefined
|
||||
? 'skip'
|
||||
: member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy'),
|
||||
retryPolicy: retryPolicy(source.retryPolicy, operationType),
|
||||
enabled: source.enabled === undefined ? true : source.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduledTaskRequest {
|
||||
const source = object(value, 'schedule update');
|
||||
exactKeys(source, UPDATE_KEYS);
|
||||
if (!Number.isSafeInteger(source.version) || (source.version as number) < 1)
|
||||
throw new TypeError('version must be a positive integer');
|
||||
if (Object.keys(source).length === 1)
|
||||
throw new TypeError('At least one editable field is required');
|
||||
|
||||
const result: Record<string, unknown> = { version: source.version };
|
||||
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
|
||||
if (source.operationType !== undefined)
|
||||
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||
if (source.cronExpression !== undefined)
|
||||
result.cronExpression = cronExpression(source.cronExpression);
|
||||
if (source.timezone !== undefined) {
|
||||
if (source.timezone !== AUTOMATION_TIMEZONE)
|
||||
throw new TypeError('timezone must be Asia/Shanghai');
|
||||
result.timezone = AUTOMATION_TIMEZONE;
|
||||
}
|
||||
if (source.targetSelector !== undefined)
|
||||
result.targetSelector = targetSelector(source.targetSelector);
|
||||
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
|
||||
if (source.effectiveStartAt !== undefined)
|
||||
result.effectiveStartAt =
|
||||
source.effectiveStartAt === null
|
||||
? null
|
||||
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
|
||||
if (source.effectiveEndAt !== undefined)
|
||||
result.effectiveEndAt =
|
||||
source.effectiveEndAt === null ? null : isoDate(source.effectiveEndAt, 'effectiveEndAt');
|
||||
if (source.misfirePolicy !== undefined)
|
||||
result.misfirePolicy = member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy');
|
||||
if (source.overlapPolicy !== undefined)
|
||||
result.overlapPolicy = member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy');
|
||||
if (source.retryPolicy !== undefined)
|
||||
result.retryPolicy = retryPolicy(
|
||||
source.retryPolicy,
|
||||
(result.operationType ?? 'restart-service') as ScheduledOperationType,
|
||||
);
|
||||
if (source.enabled !== undefined) {
|
||||
if (typeof source.enabled !== 'boolean') throw new TypeError('enabled must be boolean');
|
||||
result.enabled = source.enabled;
|
||||
}
|
||||
return result as unknown as UpdateScheduledTaskRequest;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './audit.js';
|
||||
export * from './automation.js';
|
||||
export * from './errors.js';
|
||||
export * from './instances.js';
|
||||
export * from './jobs.js';
|
||||
|
||||
Generated
+42
@@ -45,6 +45,9 @@ importers:
|
||||
better-sqlite3:
|
||||
specifier: 12.11.1
|
||||
version: 12.11.1
|
||||
cron-parser:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)
|
||||
@@ -67,6 +70,12 @@ importers:
|
||||
'@multi-simadmin/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/contracts
|
||||
animal-island-ui:
|
||||
specifier: 1.3.0
|
||||
version: 1.3.0(classnames@2.5.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
classnames:
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -1015,6 +1024,14 @@ packages:
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
animal-island-ui@1.3.0:
|
||||
resolution: {integrity: sha512-74A7BPX9bKnv8PJkjrPay9qtBn1/IuHes3I3Peu41ljSWHAVVa8RmpmOJCRs7S637q39N0lwrdNyP+wNyfodIA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
classnames: ^2.5.1
|
||||
react: '>=17.0.0'
|
||||
react-dom: '>=17.0.0'
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1108,6 +1125,9 @@ packages:
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1129,6 +1149,10 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cron-parser@5.6.2:
|
||||
resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1609,6 +1633,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -2996,6 +3024,12 @@ snapshots:
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
animal-island-ui@1.3.0(classnames@2.5.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
classnames: 2.5.1
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -3085,6 +3119,8 @@ snapshots:
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -3099,6 +3135,10 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cron-parser@5.6.2:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -3585,6 +3625,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
|
||||
+523
-38
@@ -2,7 +2,7 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { extname, join } from 'node:path';
|
||||
@@ -17,15 +17,63 @@ const DIST = fileURLToPath(new URL('../apps/web/dist/', import.meta.url));
|
||||
const CLEAN_DIST = process.argv.includes('--clean-dist');
|
||||
const FORBIDDEN_PORT = 8788;
|
||||
const EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN);
|
||||
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR?.trim();
|
||||
const FLEET_FIXTURE = process.env.E2E_FLEET_FIXTURE !== '0';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.CHROME_BIN,
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
].filter(Boolean);
|
||||
|
||||
const EMPTY_PAGE = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
|
||||
const SCHEDULE_FIXTURE = {
|
||||
id: 'morning-restart',
|
||||
name: '每日服务重启',
|
||||
operationType: 'restart-service',
|
||||
cronExpression: '0 4 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
targetSelector: { mode: 'tags', match: 'all', tags: ['核心', '5G'] },
|
||||
misfirePolicy: 'skip',
|
||||
overlapPolicy: 'skip',
|
||||
retryPolicy: { maxRetries: 1, intervalSeconds: 60 },
|
||||
enabled: true,
|
||||
version: 3,
|
||||
createdBy: 'operator',
|
||||
updatedBy: 'operator',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
nextDueAt: '2026-07-30T20:00:00.000Z',
|
||||
};
|
||||
const FLEET_FIXTURE_ITEMS = [
|
||||
{
|
||||
id: 'edge-north',
|
||||
name: 'Edge North',
|
||||
origin: 'http://10.0.0.21',
|
||||
tags: ['核心', '5G'],
|
||||
revision: 4,
|
||||
},
|
||||
{ id: 'lab-backup', name: 'Lab Backup', origin: 'http://10.0.0.22', tags: ['备用'], revision: 2 },
|
||||
{
|
||||
id: 'harbour-gateway',
|
||||
name: 'Harbour Gateway',
|
||||
origin: 'http://10.0.0.23',
|
||||
tags: ['香港', '公网'],
|
||||
revision: 7,
|
||||
},
|
||||
{
|
||||
id: 'field-unit',
|
||||
name: 'Field Unit 07',
|
||||
origin: 'http://10.0.0.24',
|
||||
tags: ['外场'],
|
||||
revision: 3,
|
||||
},
|
||||
];
|
||||
|
||||
async function builtAssetHandler(request, response) {
|
||||
const pathname = new URL(request.url ?? '/', 'http://e2e.local').pathname;
|
||||
@@ -35,8 +83,45 @@ async function builtAssetHandler(request, response) {
|
||||
return;
|
||||
}
|
||||
let body;
|
||||
if (pathname === '/api/v1/instances') body = EMPTY_PAGE;
|
||||
if (pathname === '/api/v1/jobs' || pathname === '/api/v1/audit') body = EMPTY_PAGE;
|
||||
if (pathname === '/api/v1/auth/status')
|
||||
body = { configured: false, protectionEnabled: false, authenticated: false };
|
||||
if (pathname === '/api/v1/instances')
|
||||
body = FLEET_FIXTURE
|
||||
? {
|
||||
items: FLEET_FIXTURE_ITEMS,
|
||||
page: { page: 1, pageSize: 25, total: FLEET_FIXTURE_ITEMS.length },
|
||||
}
|
||||
: EMPTY_PAGE;
|
||||
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname)) {
|
||||
const fixtureIndex = FLEET_FIXTURE_ITEMS.findIndex((item) => pathname.includes(item.id));
|
||||
body = {
|
||||
version: `2.4.${Math.max(0, fixtureIndex)}`,
|
||||
cpuPercent: [24, 61, 38, 76][Math.max(0, fixtureIndex)],
|
||||
memoryPercent: [52, 43, 68, 71][Math.max(0, fixtureIndex)],
|
||||
maxTemperatureCelsius: [42, 47, 44, 58][Math.max(0, fixtureIndex)],
|
||||
phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`],
|
||||
};
|
||||
}
|
||||
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname))
|
||||
body = { messages: [] };
|
||||
if (
|
||||
pathname === '/api/v1/jobs' ||
|
||||
pathname === '/api/v1/audit' ||
|
||||
pathname === '/api/v1/automation/runs'
|
||||
)
|
||||
body = EMPTY_PAGE;
|
||||
if (pathname === '/api/v1/automation/schedules')
|
||||
body = { items: [SCHEDULE_FIXTURE], page: { page: 1, pageSize: 25, total: 1 } };
|
||||
if (pathname === '/api/v1/automation/cron/preview')
|
||||
body = {
|
||||
occurrences: [
|
||||
'2026-07-31T01:00:00.000Z',
|
||||
'2026-08-01T01:00:00.000Z',
|
||||
'2026-08-02T01:00:00.000Z',
|
||||
'2026-08-03T01:00:00.000Z',
|
||||
'2026-08-04T01:00:00.000Z',
|
||||
],
|
||||
};
|
||||
if (body !== undefined) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
@@ -252,7 +337,7 @@ async function press(cdp, key, code, windowsVirtualKeyCode, text) {
|
||||
async function keyboardNavigate(cdp, label, expectedPath, expectedHeading) {
|
||||
const focused = await evaluate(
|
||||
cdp,
|
||||
`(() => { const link = [...document.querySelectorAll('nav[aria-label="Global navigation"] a')].find((item) => item.textContent.trim() === ${JSON.stringify(label)}); if (!link) return false; link.focus(); return document.activeElement === link; })()`,
|
||||
`(() => { const link = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].find((item) => item.textContent.trim() === ${JSON.stringify(label)}); if (!link) return false; link.focus(); return document.activeElement === link; })()`,
|
||||
);
|
||||
assert.equal(focused, true, `${label} navigation link must accept keyboard focus`);
|
||||
await press(cdp, 'Enter', 'Enter', 13, '\r');
|
||||
@@ -272,6 +357,115 @@ async function keyboardNavigate(cdp, label, expectedPath, expectedHeading) {
|
||||
);
|
||||
}
|
||||
|
||||
async function captureScreenshot(cdp, filename) {
|
||||
if (!SCREENSHOT_DIR) return;
|
||||
await mkdir(SCREENSHOT_DIR, { recursive: true });
|
||||
const result = await cdp.send('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
await writeFile(join(SCREENSHOT_DIR, filename), Buffer.from(result.data, 'base64'));
|
||||
}
|
||||
|
||||
async function navigate(cdp, url) {
|
||||
const loaded = cdp.once('Page.loadEventFired');
|
||||
await cdp.send('Page.navigate', { url });
|
||||
await loaded;
|
||||
}
|
||||
|
||||
async function clickButton(cdp, label) {
|
||||
const clicked = await evaluate(
|
||||
cdp,
|
||||
`(() => { const button = [...document.querySelectorAll('button')].find((item) => item.textContent.trim() === ${JSON.stringify(label)}); if (!button) return false; button.click(); return true; })()`,
|
||||
);
|
||||
assert.equal(clicked, true, `Button not found: ${label}`);
|
||||
}
|
||||
|
||||
async function fleetLayout(cdp) {
|
||||
return evaluate(
|
||||
cdp,
|
||||
`(() => {
|
||||
const grid = document.querySelector('.fleet-card-grid');
|
||||
const workspace = document.querySelector('.fleet-workspace');
|
||||
const sidebar = document.querySelector('.fleet-sidebar');
|
||||
const sidebarHeader = document.querySelector('.fleet-sidebar-header');
|
||||
const results = document.querySelector('.fleet-results-pane');
|
||||
const groups = document.querySelector('.fleet-group-tabs');
|
||||
const firstCard = document.querySelector('.fleet-card');
|
||||
const version = document.querySelector('.fleet-card-version');
|
||||
const firstResource = document.querySelector('.fleet-resource-row');
|
||||
const telemetry = document.querySelector('.fleet-card-telemetry');
|
||||
const footer = document.querySelector('.fleet-card-footer');
|
||||
const menuTrigger = document.querySelector('.fleet-card-menu-trigger');
|
||||
const menuPanel = document.querySelector('.fleet-card-menu-panel');
|
||||
const checkbox = document.querySelector('.fleet-card-select');
|
||||
const bounds = (element) => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height };
|
||||
};
|
||||
const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => {
|
||||
const label = item.querySelector('dt');
|
||||
const value = item.querySelector('dd');
|
||||
return {
|
||||
labelHeight: label?.getBoundingClientRect().height ?? 0,
|
||||
labelFits: label ? label.scrollWidth <= label.clientWidth : false,
|
||||
valueHeight: value?.getBoundingClientRect().height ?? 0,
|
||||
valueFits: value ? value.scrollWidth <= value.clientWidth : false,
|
||||
};
|
||||
});
|
||||
const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
return { text: item.textContent.trim(), left: rect.left, right: rect.right };
|
||||
});
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
columns: grid ? getComputedStyle(grid).gridTemplateColumns.split(' ').length : 0,
|
||||
groupDisplay: groups ? getComputedStyle(groups).display : null,
|
||||
resourceDisplay: firstResource ? getComputedStyle(firstResource).display : null,
|
||||
telemetryDisplay: telemetry ? getComputedStyle(telemetry).display : null,
|
||||
footerDisplay: footer ? getComputedStyle(footer).display : null,
|
||||
workspaceDisplay: workspace ? getComputedStyle(workspace).display : null,
|
||||
sidebarHeaderDisplay: sidebarHeader ? getComputedStyle(sidebarHeader).display : null,
|
||||
sidebar: bounds(sidebar),
|
||||
results: bounds(results),
|
||||
groups: bounds(groups),
|
||||
firstCard: bounds(firstCard),
|
||||
version: bounds(version),
|
||||
versionText: version?.textContent.trim() ?? null,
|
||||
versionFits: version ? version.scrollWidth <= version.clientWidth : false,
|
||||
firstResource: bounds(firstResource),
|
||||
telemetry: bounds(telemetry),
|
||||
footer: bounds(footer),
|
||||
menuTrigger: bounds(menuTrigger),
|
||||
menuPanel: bounds(menuPanel),
|
||||
checkbox: bounds(checkbox),
|
||||
hardwareEntries,
|
||||
navigation,
|
||||
};
|
||||
})()`,
|
||||
);
|
||||
}
|
||||
|
||||
async function pageLayout(cdp) {
|
||||
return evaluate(
|
||||
cdp,
|
||||
`(() => { const layout = document.querySelector('.app-layout'); if (!layout) return null; const rect = layout.getBoundingClientRect(); const style = getComputedStyle(layout); const paddingLeft = Number.parseFloat(style.paddingLeft); const paddingRight = Number.parseFloat(style.paddingRight); return { viewport: document.documentElement.clientWidth, left: rect.left + paddingLeft, right: rect.right - paddingRight, width: rect.width - paddingLeft - paddingRight }; })()`,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertWidePageGutters(cdp, label) {
|
||||
const layout = await pageLayout(cdp);
|
||||
assert.ok(layout, `${label} application layout must render`);
|
||||
const rightGap = layout.viewport - layout.right;
|
||||
assert.equal(
|
||||
layout.left >= 16 && layout.left <= 24 && rightGap >= 16 && rightGap <= 24,
|
||||
true,
|
||||
`${label} must keep 16px to 24px outer gutters: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let http;
|
||||
let chrome;
|
||||
let cdp;
|
||||
@@ -351,20 +545,210 @@ try {
|
||||
assert.deepEqual(unknownApiProblem, { error: 'Not Found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const loaded = cdp.once('Page.loadEventFired');
|
||||
await cdp.send('Page.navigate', { url: `${origin}/fleet` });
|
||||
await loaded;
|
||||
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('h1')?.textContent.trim() === 'Fleet'`,
|
||||
'Fleet did not render',
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.body.textContent.includes('No instances are configured.')`,
|
||||
'Fleet mock data did not load',
|
||||
);
|
||||
const viewportCases = [
|
||||
{ width: 390, height: 844, columns: 1, sidebarMode: 'stacked', mobile: true },
|
||||
{ width: 768, height: 900, columns: 2, sidebarMode: 'stacked', mobile: false },
|
||||
{ width: 1024, height: 900, columns: 2, sidebarMode: 'left', mobile: false },
|
||||
{ width: 1440, height: 1000, columns: 4, sidebarMode: 'left', mobile: false },
|
||||
{ width: 1920, height: 1080, columns: 5, sidebarMode: 'left', mobile: false },
|
||||
];
|
||||
for (const viewport of viewportCases) {
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: viewport.mobile,
|
||||
});
|
||||
await navigate(cdp, `${origin}/fleet`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('h1')?.textContent.trim() === '节点'`,
|
||||
`Fleet did not render at ${viewport.width}px`,
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelectorAll('[role="article"]').length === ${FLEET_FIXTURE_ITEMS.length}`,
|
||||
`Fleet mock data did not load at ${viewport.width}px`,
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`,
|
||||
`Fleet SimAdmin version did not synchronize at ${viewport.width}px`,
|
||||
);
|
||||
const layout = await fleetLayout(cdp);
|
||||
assert.equal(
|
||||
layout.scrollWidth <= layout.viewport,
|
||||
true,
|
||||
`${viewport.width}px layout overflows: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
assert.equal(layout.columns, viewport.columns, `${viewport.width}px Fleet column count`);
|
||||
assert.equal(layout.groupDisplay, 'flex', `${viewport.width}px tag groups must use flex`);
|
||||
assert.equal(layout.resourceDisplay, 'grid', `${viewport.width}px resource rows must use grid`);
|
||||
assert.equal(layout.telemetryDisplay, 'grid', `${viewport.width}px telemetry must use grid`);
|
||||
assert.equal(layout.footerDisplay, 'flex', `${viewport.width}px SMS footer must use flex`);
|
||||
assert.equal(
|
||||
layout.workspaceDisplay,
|
||||
'grid',
|
||||
`${viewport.width}px Fleet workspace must use the responsive grid`,
|
||||
);
|
||||
assert.notEqual(
|
||||
layout.sidebarHeaderDisplay,
|
||||
'none',
|
||||
`${viewport.width}px Fleet sidebar heading must remain visible`,
|
||||
);
|
||||
assert.ok(layout.sidebar && layout.results, `${viewport.width}px Fleet panes must render`);
|
||||
assert.ok(
|
||||
layout.groups &&
|
||||
layout.firstCard &&
|
||||
layout.version &&
|
||||
layout.firstResource &&
|
||||
layout.telemetry &&
|
||||
layout.footer &&
|
||||
layout.menuTrigger,
|
||||
`${viewport.width}px Fleet information hierarchy must render`,
|
||||
);
|
||||
for (const [name, bounds] of [
|
||||
['tag groups', layout.groups],
|
||||
['first card', layout.firstCard],
|
||||
]) {
|
||||
assert.equal(
|
||||
bounds.left >= layout.results.left - 0.5 && bounds.right <= layout.results.right + 0.5,
|
||||
true,
|
||||
`${viewport.width}px ${name} leaves results pane: ${JSON.stringify({ bounds, results: layout.results })}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
layout.versionText,
|
||||
'SimAdmin 2.4.0',
|
||||
`${viewport.width}px card must render the upstream SimAdmin version`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.versionFits,
|
||||
true,
|
||||
`${viewport.width}px SimAdmin version must remain fully visible`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.firstResource.left >= layout.firstCard.left - 0.5 &&
|
||||
layout.firstResource.right <= layout.firstCard.right + 0.5,
|
||||
true,
|
||||
`${viewport.width}px resource row leaves card: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
for (const [name, bounds] of [
|
||||
['SimAdmin version', layout.version],
|
||||
['telemetry', layout.telemetry],
|
||||
['SMS footer', layout.footer],
|
||||
['action menu trigger', layout.menuTrigger],
|
||||
]) {
|
||||
assert.equal(
|
||||
bounds.left >= layout.firstCard.left - 0.5 && bounds.right <= layout.firstCard.right + 0.5,
|
||||
true,
|
||||
`${viewport.width}px ${name} leaves card: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
layout.menuTrigger.width >= 40 && layout.menuTrigger.height >= 40,
|
||||
true,
|
||||
`${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
|
||||
layout.hardwareEntries.every(
|
||||
(entry) =>
|
||||
entry.labelFits &&
|
||||
entry.valueFits &&
|
||||
entry.labelHeight <= 20 &&
|
||||
entry.valueHeight <= 20,
|
||||
),
|
||||
true,
|
||||
`${viewport.width}px hardware facts wrap or clip: ${JSON.stringify(layout.hardwareEntries)}`,
|
||||
);
|
||||
if (viewport.sidebarMode === 'left') {
|
||||
assert.equal(
|
||||
layout.sidebar.right < layout.results.left,
|
||||
true,
|
||||
`${viewport.width}px Fleet sidebar must remain left of results: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
layout.sidebar.bottom < layout.results.top,
|
||||
true,
|
||||
`${viewport.width}px Fleet sidebar must stack above results: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.sidebar.left <= layout.results.left && layout.sidebar.right >= layout.results.right,
|
||||
true,
|
||||
`${viewport.width}px stacked Fleet sidebar must span the results width: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
}
|
||||
assert.equal(layout.navigation.length, 3, 'Global navigation must have three entries');
|
||||
assert.deepEqual(
|
||||
layout.navigation.map((item) => item.text),
|
||||
['节点', '自动化', '设置'],
|
||||
);
|
||||
assert.equal(
|
||||
layout.navigation.every((item) => item.left >= 0 && item.right <= layout.viewport),
|
||||
true,
|
||||
`${viewport.width}px navigation leaves viewport: ${JSON.stringify(layout.navigation)}`,
|
||||
);
|
||||
assert.equal(
|
||||
await evaluate(cdp, `document.querySelectorAll('.fleet-card-select input').length`),
|
||||
0,
|
||||
'Fleet checkboxes must be hidden outside batch mode',
|
||||
);
|
||||
await captureScreenshot(cdp, `warm-fleet-${viewport.width}.png`);
|
||||
if (viewport.width === 390) {
|
||||
await evaluate(
|
||||
cdp,
|
||||
`(() => { const card = document.querySelector('.fleet-card'); window.scrollBy({ top: card.getBoundingClientRect().top - 128 }); })()`,
|
||||
);
|
||||
await delay(200);
|
||||
await captureScreenshot(cdp, 'warm-fleet-390-cards.png');
|
||||
}
|
||||
if (viewport.width === 1440) {
|
||||
await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('.fleet-card-menu-panel') !== null`,
|
||||
'Fleet card action menu did not open',
|
||||
);
|
||||
const menuLayout = await fleetLayout(cdp);
|
||||
assert.ok(menuLayout.menuPanel, 'Fleet card action menu geometry missing');
|
||||
assert.equal(
|
||||
menuLayout.menuPanel.left >= menuLayout.firstCard.left - 0.5 &&
|
||||
menuLayout.menuPanel.right <= menuLayout.firstCard.right + 0.5 &&
|
||||
menuLayout.menuPanel.right <= menuLayout.viewport + 0.5,
|
||||
true,
|
||||
`Fleet card action menu leaves its card or viewport: ${JSON.stringify(menuLayout)}`,
|
||||
);
|
||||
await captureScreenshot(cdp, 'warm-fleet-1440-menu.png');
|
||||
await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
|
||||
}
|
||||
if (viewport.width === 390) {
|
||||
await clickButton(cdp, '批量选择');
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelectorAll('.fleet-card-select input').length === ${FLEET_FIXTURE_ITEMS.length}`,
|
||||
'Mobile Fleet checkboxes did not appear in batch mode',
|
||||
);
|
||||
const mobileSelectionLayout = await fleetLayout(cdp);
|
||||
assert.ok(
|
||||
mobileSelectionLayout.checkbox && mobileSelectionLayout.firstCard,
|
||||
'Mobile Fleet selection geometry missing',
|
||||
);
|
||||
assert.equal(
|
||||
mobileSelectionLayout.checkbox.left >
|
||||
mobileSelectionLayout.firstCard.left + mobileSelectionLayout.firstCard.width / 2,
|
||||
true,
|
||||
`Mobile Fleet checkbox must stay in the card upper-right: ${JSON.stringify(mobileSelectionLayout)}`,
|
||||
);
|
||||
assert.equal(
|
||||
mobileSelectionLayout.checkbox.top <
|
||||
mobileSelectionLayout.firstCard.top + mobileSelectionLayout.firstCard.height / 3,
|
||||
true,
|
||||
`Mobile Fleet checkbox must stay in the card header: ${JSON.stringify(mobileSelectionLayout)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
await evaluate(
|
||||
@@ -374,34 +758,134 @@ try {
|
||||
true,
|
||||
'Fleet search must accept focus',
|
||||
);
|
||||
for (const character of 'edge')
|
||||
await press(
|
||||
cdp,
|
||||
character,
|
||||
`Key${character.toUpperCase()}`,
|
||||
character.toUpperCase().charCodeAt(0),
|
||||
character,
|
||||
);
|
||||
assert.equal(await evaluate(cdp, `document.querySelector('input[type="search"]').value`), 'edge');
|
||||
await clickButton(cdp, '批量选择');
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelectorAll('.fleet-card-select input').length === ${FLEET_FIXTURE_ITEMS.length}`,
|
||||
'Fleet checkboxes did not appear in batch mode',
|
||||
);
|
||||
await evaluate(cdp, `document.querySelector('.fleet-card-select input').click()`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('.fleet-card-select input').checked === true`,
|
||||
'Fleet selection did not update',
|
||||
);
|
||||
const selectionLayout = await fleetLayout(cdp);
|
||||
assert.ok(
|
||||
selectionLayout.checkbox && selectionLayout.firstCard,
|
||||
'Fleet selection geometry missing',
|
||||
);
|
||||
assert.equal(
|
||||
selectionLayout.checkbox.left >
|
||||
selectionLayout.firstCard.left + selectionLayout.firstCard.width / 2,
|
||||
true,
|
||||
`Fleet checkbox must stay in the card upper-right: ${JSON.stringify(selectionLayout)}`,
|
||||
);
|
||||
assert.equal(
|
||||
selectionLayout.checkbox.top <
|
||||
selectionLayout.firstCard.top + selectionLayout.firstCard.height / 3,
|
||||
true,
|
||||
`Fleet checkbox must stay in the card header: ${JSON.stringify(selectionLayout)}`,
|
||||
);
|
||||
|
||||
await keyboardNavigate(cdp, 'Jobs', '/jobs', 'Jobs');
|
||||
await assertWidePageGutters(cdp, 'Nodes');
|
||||
await keyboardNavigate(cdp, '设置', '/settings/instances', '实例');
|
||||
await assertWidePageGutters(cdp, 'Settings');
|
||||
await keyboardNavigate(cdp, '自动化', '/automation', '自动化');
|
||||
await assertWidePageGutters(cdp, 'Automation');
|
||||
assert.deepEqual(
|
||||
await evaluate(
|
||||
cdp,
|
||||
`[...document.querySelectorAll('[role="tab"]')].map((item) => item.textContent.trim())`,
|
||||
),
|
||||
['计划任务', '执行记录', '操作审计'],
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.body.textContent.includes('No jobs match the current query.')`,
|
||||
'Jobs mock data did not load',
|
||||
`document.querySelectorAll('.row-action-trigger').length === 1 && document.querySelector('.row-actions') === null`,
|
||||
'Schedule row menu trigger did not render',
|
||||
);
|
||||
await keyboardNavigate(cdp, 'Audit', '/audit', 'Audit');
|
||||
const createButtonStyle = await evaluate(
|
||||
cdp,
|
||||
`(() => { const button = [...document.querySelectorAll('button')].find((item) => item.textContent.trim() === '创建任务'); const style = getComputedStyle(button); return { color: style.color, backgroundColor: style.backgroundColor, opacity: style.opacity }; })()`,
|
||||
);
|
||||
assert.equal(
|
||||
createButtonStyle.backgroundColor,
|
||||
'rgb(23, 143, 132)',
|
||||
`Create button lost its primary fill: ${JSON.stringify(createButtonStyle)}`,
|
||||
);
|
||||
const shortPageGeometry = await evaluate(
|
||||
cdp,
|
||||
`(() => ({ footerBottom: document.querySelector('.app-footer').getBoundingClientRect().bottom, viewportBottom: innerHeight }))()`,
|
||||
);
|
||||
assert.equal(
|
||||
Math.abs(shortPageGeometry.footerBottom - shortPageGeometry.viewportBottom) <= 1,
|
||||
true,
|
||||
`Short-page footer is not anchored to the viewport: ${JSON.stringify(shortPageGeometry)}`,
|
||||
);
|
||||
const scheduleTableGeometry = await evaluate(
|
||||
cdp,
|
||||
`(() => { const table = document.querySelector('.schedule-table-wrap'); return { clientHeight: table.clientHeight, scrollHeight: table.scrollHeight, overflowY: getComputedStyle(table).overflowY }; })()`,
|
||||
);
|
||||
assert.equal(
|
||||
scheduleTableGeometry.overflowY,
|
||||
'hidden',
|
||||
`Schedule table has an unnecessary vertical scrollbar: ${JSON.stringify(scheduleTableGeometry)}`,
|
||||
);
|
||||
await evaluate(cdp, `document.querySelector('.row-action-trigger').click()`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.body.textContent.includes('No audit events match the current query.')`,
|
||||
'Audit mock data did not load',
|
||||
`document.querySelectorAll('.row-actions button').length === 4`,
|
||||
'Schedule row menu actions did not render',
|
||||
);
|
||||
await keyboardNavigate(cdp, 'Settings', '/settings/instances', 'Instances');
|
||||
await captureScreenshot(cdp, 'warm-automation-1440.png');
|
||||
await evaluate(cdp, `document.querySelector('.row-action-trigger').click()`);
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 390,
|
||||
height: 844,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
});
|
||||
await clickButton(cdp, '创建任务');
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.body.textContent.includes('No instances are configured.')`,
|
||||
'Settings mock data did not load',
|
||||
`document.querySelector('[role="dialog"]') !== null`,
|
||||
'Schedule drawer did not open',
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`(() => { const rect = document.querySelector('[role="dialog"]').getBoundingClientRect(); return rect.left >= 0 && rect.right <= innerWidth + 0.5; })()`,
|
||||
'Schedule drawer did not settle inside the viewport',
|
||||
);
|
||||
const drawerLayout = await evaluate(
|
||||
cdp,
|
||||
`(() => { const dialog = document.querySelector('[role="dialog"]'); const rect = dialog.getBoundingClientRect(); return { viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth, left: rect.left, right: rect.right }; })()`,
|
||||
);
|
||||
assert.equal(
|
||||
drawerLayout.scrollWidth <= drawerLayout.viewport,
|
||||
true,
|
||||
'Schedule drawer overflows',
|
||||
);
|
||||
assert.equal(
|
||||
drawerLayout.left >= 0 && drawerLayout.right <= drawerLayout.viewport + 0.5,
|
||||
true,
|
||||
`Schedule drawer leaves viewport: ${JSON.stringify(drawerLayout)}`,
|
||||
);
|
||||
await captureScreenshot(cdp, 'warm-automation-drawer-390.png');
|
||||
|
||||
await navigate(cdp, `${origin}/jobs`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '执行记录'`,
|
||||
'/jobs alias did not select execution history',
|
||||
);
|
||||
await navigate(cdp, `${origin}/audit`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
|
||||
'/audit alias did not select operation audit',
|
||||
);
|
||||
await keyboardNavigate(cdp, '设置', '/settings/instances', '实例');
|
||||
|
||||
assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`);
|
||||
console.log(`PASS real Chrome E2E (${chromeBinary})`);
|
||||
@@ -411,7 +895,7 @@ try {
|
||||
: `PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`,
|
||||
);
|
||||
console.log(
|
||||
'PASS keyboard navigation and rendered API states: /fleet -> /jobs -> /audit -> /settings/instances',
|
||||
'PASS three-item navigation, Automation drawer and aliases, batch selection, and 390/768/1024/1440/1920 responsive layouts',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
@@ -425,7 +909,8 @@ try {
|
||||
http.closeAllConnections?.();
|
||||
await new Promise((resolve) => http.close(resolve));
|
||||
}
|
||||
if (profile) await rm(profile, { recursive: true, force: true });
|
||||
if (profile)
|
||||
await rm(profile, { recursive: true, force: true, maxRetries: 8, retryDelay: 125 });
|
||||
if (CLEAN_DIST) await rm(DIST, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^animal-island-ui$/u,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./apps/web/node_modules/animal-island-ui/dist/cjs/index.cjs', import.meta.url),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
include: ['apps/**/*.test.{ts,tsx}', 'packages/contracts/**/*.test.ts'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user