feat(ql3): add bounded legacy panel cron adapter

This commit is contained in:
whyour
2026-09-02 06:18:44 +08:00
parent d6595912a2
commit 15d970be41
12 changed files with 691 additions and 17 deletions
@@ -37,6 +37,7 @@ import type {
LocalApiSecretListRoute,
LocalApiSecretPutRoute,
} from '../secret/secretRoutes';
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation =
@@ -125,6 +126,13 @@ export type LocalApiAdmissionOperation =
| Readonly<{
operationId: 'secret.put';
projectId: string;
}>
| Readonly<{
operationId: 'panel.cron.list';
projectId: string;
page: number;
size: number;
maximumRows: number;
}>;
export interface LocalApiAdmissionRequest {
@@ -168,6 +176,7 @@ export interface LocalApiAdmissionOptions {
readonly triggerPutRoute: LocalApiTriggerPutRoute;
readonly secretListRoute: LocalApiSecretListRoute;
readonly secretPutRoute: LocalApiSecretPutRoute;
readonly panelCronListRoute: PanelCronListRoute;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
@@ -253,6 +262,7 @@ export function createLocalApiAdmission(
typeof options.triggerPutRoute?.handle !== 'function' ||
typeof options.secretListRoute?.handle !== 'function' ||
typeof options.secretPutRoute?.handle !== 'function' ||
typeof options.panelCronListRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
@@ -393,7 +403,8 @@ export function createLocalApiAdmission(
: request.operation.operationId === 'task.list' ||
request.operation.operationId === 'task.get' ||
request.operation.operationId === 'trigger.list' ||
request.operation.operationId === 'trigger.get'
request.operation.operationId === 'trigger.get' ||
request.operation.operationId === 'panel.cron.list'
? 'task.read'
: request.operation.operationId === 'secret.list'
? 'secret.manage'
@@ -553,6 +564,14 @@ export function createLocalApiAdmission(
? { after: request.operation.after }
: {}),
});
case 'panel.cron.list':
if (body !== null) return response(400, 'invalid_request_body');
return options.panelCronListRoute.handle({
projectId: request.operation.projectId,
page: request.operation.page,
size: request.operation.size,
maximumRows: request.operation.maximumRows,
});
case 'task.put':
case 'task.authoring':
case 'trigger.put':
@@ -31,6 +31,7 @@ import {
createLocalApiSecretListRoute,
createLocalApiSecretPutRoute,
} from '../secret/secretRoutes';
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent {
@@ -221,6 +222,10 @@ export function createLocalApiProductSurface(
? {}
: { randomUuid: options.randomUuid }),
});
const panelCronListRoute = createPanelCronListRoute({
tasks: authority.taskDefinitions,
triggers: authority.triggers,
});
const admission = createLocalApiAdmission({
authenticator,
policy,
@@ -241,6 +246,7 @@ export function createLocalApiProductSurface(
triggerPutRoute,
secretListRoute,
secretPutRoute,
panelCronListRoute,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
@@ -0,0 +1,163 @@
import {
BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
createBuiltInTriggerSpecSemanticRegistry,
normalizeTriggerRecord,
type TriggerSource,
} from '@qinglong/runtime-core/trigger';
import {
normalizeTaskDefinitionRecord,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import type { LocalApiResponse } from '../transport/contract';
const MAX_PANEL_PAGE_SIZE = 64;
export interface PanelCronListRequest {
readonly projectId: string;
readonly page: number;
readonly size: number;
readonly maximumRows: number;
}
export interface PanelCronListRoute {
handle(request: Readonly<PanelCronListRequest>): Promise<LocalApiResponse>;
}
export interface PanelCronListSources {
readonly tasks: Pick<TaskDefinitionSource, 'findTaskDefinitionRevision'>;
readonly triggers: Pick<TriggerSource, 'listTriggers'>;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): LocalApiResponse {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function validRequest(request: Readonly<PanelCronListRequest>): boolean {
return (
typeof request.projectId === 'string' &&
request.projectId.length > 0 &&
Buffer.byteLength(request.projectId, 'utf8') <= 128 &&
Number.isSafeInteger(request.page) &&
request.page >= 1 &&
Number.isSafeInteger(request.size) &&
request.size >= 1 &&
request.size <= MAX_PANEL_PAGE_SIZE &&
Number.isSafeInteger(request.maximumRows) &&
request.maximumRows >= 1 &&
request.maximumRows <= 256 &&
request.page * request.size <= request.maximumRows
);
}
export function createPanelCronListRoute(
sources: Readonly<PanelCronListSources>,
): Readonly<PanelCronListRoute> {
if (
!sources ||
typeof sources !== 'object' ||
Array.isArray(sources) ||
typeof sources.tasks?.findTaskDefinitionRevision !== 'function' ||
typeof sources.triggers?.listTriggers !== 'function'
) {
throw new TypeError('Panel Cron list sources are invalid');
}
const semantics = createBuiltInTriggerSpecSemanticRegistry();
return Object.freeze({
async handle(request: Readonly<PanelCronListRequest>) {
if (!validRequest(request)) {
return response(400, { code: 400, message: '参数错误' });
}
try {
const scanLimit = request.page * request.size;
const page = await sources.triggers.listTriggers({
projectId: request.projectId,
limit: scanLimit,
});
if (
!page ||
!Array.isArray(page.triggers) ||
page.triggers.length > scanLimit ||
typeof page.truncated !== 'boolean' ||
page.truncated !== Boolean(page.next)
) {
throw new TypeError('Trigger page is unavailable');
}
const start = (request.page - 1) * request.size;
const selected = page.triggers.slice(start, scanLimit);
const data: Record<string, unknown>[] = [];
for (const rawTrigger of selected) {
const trigger = normalizeTriggerRecord(rawTrigger);
if (
trigger.projectId !== request.projectId ||
trigger.spec.schema !== BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA
) {
throw new TypeError('Trigger cannot be projected as a Cron');
}
const spec = semantics.normalize({
projectId: trigger.projectId,
triggerId: trigger.triggerId,
taskId: trigger.taskId,
taskRevision: trigger.taskRevision,
spec: trigger.spec,
});
const rawTask = await sources.tasks.findTaskDefinitionRevision(
request.projectId,
trigger.taskId,
trigger.taskRevision,
);
if (!rawTask) throw new TypeError('Pinned Task is unavailable');
const task = normalizeTaskDefinitionRecord(rawTask);
if (
task.projectId !== request.projectId ||
task.taskId !== trigger.taskId ||
task.revision !== trigger.taskRevision ||
task.contentDigest !== trigger.taskContentDigest
) {
throw new TypeError('Pinned Task identity is detached');
}
const disabled = !task.enabled || !trigger.enabled;
data.push(
Object.freeze({
id: trigger.triggerId,
name: task.name,
command: `ql3:${task.kind}:${task.taskId}@${task.revision}`,
schedule: spec.config.expression,
extra_schedules: Object.freeze([]),
status: disabled ? 2 : 1,
isDisabled: disabled ? 1 : 0,
isPinned: 0,
createdAt: new Date(trigger.createdAtMs).toISOString(),
updatedAt: new Date(trigger.updatedAtMs).toISOString(),
ql3: Object.freeze({
projectId: request.projectId,
taskId: task.taskId,
taskRevision: task.revision,
triggerId: trigger.triggerId,
triggerRevision: trigger.revision,
timezone: spec.config.timezone,
misfirePolicy: spec.config.misfirePolicy,
readOnly: true,
}),
}),
);
}
return response(200, {
code: 200,
data: Object.freeze({
data: Object.freeze(data),
total: page.triggers.length + (page.truncated ? 1 : 0),
}),
});
} catch {
return response(503, {
code: 503,
message: 'QL3 面板兼容视图暂不可用',
});
}
},
});
}
@@ -64,7 +64,8 @@ type LocalApiRouteResolution =
| 'invalid_run_log_read_query'
| 'invalid_task_list_query'
| 'invalid_trigger_list_query'
| 'invalid_secret_list_query';
| 'invalid_secret_list_query'
| 'invalid_panel_cron_list_query';
}>;
export interface LocalApiHttpSurfaceOptions {
@@ -549,6 +550,62 @@ function parseRunAttemptLogReadQuery(
return Object.freeze({ offset, length });
}
function parsePanelCronListRoute(
rawUrl: string,
profile: LocalApplicationProfile,
): LocalApiRouteResolution | null {
const separator = rawUrl.indexOf('?');
if (
separator !== rawUrl.lastIndexOf('?') ||
(separator < 0 ? rawUrl : rawUrl.slice(0, separator)) !== '/api/crons'
) {
return null;
}
try {
const query = new URLSearchParams(
separator < 0 ? '' : rawUrl.slice(separator + 1),
);
const allowed = new Set(['filters', 'page', 'searchValue', 'size', 't']);
for (const key of query.keys()) {
if (!allowed.has(key) || query.getAll(key).length !== 1) {
throw new TypeError();
}
}
const searchValue = query.get('searchValue') ?? '';
const filters = query.get('filters') ?? '{}';
const rawPage = query.get('page') ?? '1';
const rawSize = query.get('size') ?? '20';
const timestamp = query.get('t');
const page = Number(rawPage);
const size = Number(rawSize);
const maximumRows = profile === 'edge' ? 64 : 256;
if (
searchValue !== '' ||
filters !== '{}' ||
!Number.isSafeInteger(page) ||
page < 1 ||
String(page) !== rawPage ||
!Number.isSafeInteger(size) ||
size < 1 ||
size > 64 ||
String(size) !== rawSize ||
page * size > maximumRows ||
(timestamp !== null && !/^\d{1,20}$/u.test(timestamp))
) {
throw new TypeError();
}
return Object.freeze({
operationId: 'panel.cron.list',
projectId: 'default',
page,
size,
maximumRows,
});
} catch {
return Object.freeze({ errorCode: 'invalid_panel_cron_list_query' });
}
}
function route(
request: IncomingMessage,
profile: LocalApplicationProfile,
@@ -558,11 +615,15 @@ function route(
typeof rawUrl !== 'string' ||
rawUrl.length < 1 ||
Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES ||
rawUrl.includes('%') ||
rawUrl.includes('#')
) {
return null;
}
if (request.method === 'GET') {
const panelCronList = parsePanelCronListRoute(rawUrl, profile);
if (panelCronList) return panelCronList;
}
if (rawUrl.includes('%')) return null;
const separator = rawUrl.indexOf('?');
if (separator !== rawUrl.lastIndexOf('?')) return null;
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);