feat(ql3): manage canonical task runs from the existing panel

This commit is contained in:
whyour
2026-09-04 04:27:42 +08:00
parent 2005cb6ba9
commit e4ba5d405f
13 changed files with 1199 additions and 6 deletions
+372
View File
@@ -0,0 +1,372 @@
import {
qingLong3Credential,
qingLong3Session,
type QingLong3Capabilities,
} from '@/utils/qinglong3';
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DIGEST = /^[a-f0-9]{64}$/;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUSES = new Set([
...TERMINAL,
'created',
'queued',
'dispatching',
'running',
'waiting_approval',
'retry_wait',
'lost',
]);
export interface PanelTask {
taskId: string;
name: string;
revision: number;
contentDigest: string;
enabled: boolean;
}
export interface PanelRun {
id: string;
taskId: string;
taskRevision: string;
status: string;
createdAtMs: number;
latestAttempt?: { id: string; logAvailable: boolean } | null;
}
export interface RunCursor {
createdAtMs: number;
runId: string;
}
export interface RunPage {
runs: PanelRun[];
next?: RunCursor;
scanned: number;
}
export interface PreparedRunAction {
kind: 'start' | 'cancel';
target: string;
mutationId: string;
execute(): Promise<{ runId: string; status: string }>;
}
export class PanelRunControlError extends Error {
constructor(readonly code: string, readonly uncertain = false) {
super(code);
}
}
function validId(value: unknown): value is string {
return typeof value === 'string' && ID.test(value);
}
function validTime(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function cursorOf(value: any): RunCursor {
if (
!value ||
!validTime(value.createdAtMs) ||
!validId(value.runId) ||
Object.keys(value).sort().join(',') !== 'createdAtMs,runId'
) {
throw new PanelRunControlError('invalid_run_page');
}
return Object.freeze({ createdAtMs: value.createdAtMs, runId: value.runId });
}
function follows(left: RunCursor, right: RunCursor): boolean {
return (
right.createdAtMs < left.createdAtMs ||
(right.createdAtMs === left.createdAtMs && right.runId < left.runId)
);
}
function runOf(value: any): PanelRun {
if (
!value ||
!validId(value.id) ||
!validId(value.taskId) ||
typeof value.taskRevision !== 'string' ||
value.taskRevision.length > 255 ||
!value.taskRevision ||
!STATUSES.has(value.status) ||
!validTime(value.createdAtMs)
) {
throw new PanelRunControlError('invalid_run_response');
}
if (
value.latestAttempt != null &&
(!validId(value.latestAttempt.id) ||
typeof value.latestAttempt.logAvailable !== 'boolean')
) {
throw new PanelRunControlError('invalid_run_response');
}
return Object.freeze({
id: value.id,
taskId: value.taskId,
taskRevision: value.taskRevision,
status: value.status,
createdAtMs: value.createdAtMs,
...(value.latestAttempt === undefined
? {}
: {
latestAttempt:
value.latestAttempt === null
? null
: Object.freeze({
id: value.latestAttempt.id,
logAvailable: value.latestAttempt.logAvailable,
}),
}),
});
}
export function createPanelRunControl(
cron: any,
capabilities: Readonly<QingLong3Capabilities>,
) {
const projectId = cron?.ql3?.projectId,
taskId = cron?.ql3?.taskId;
if (
!validId(projectId) ||
!validId(taskId) ||
capabilities.panel.runControl !== 'task_run_v1'
) {
throw new PanelRunControlError('run_control_unavailable');
}
const session = qingLong3Session();
let disposed = false,
writing = false;
let currentTask: PanelTask | null = null,
currentRun: PanelRun | null = null;
const base = `/api/v3/projects/${projectId}`;
const isCurrent = () =>
!disposed &&
session === qingLong3Session() &&
Boolean(qingLong3Credential());
const assertCurrent = () => {
if (!isCurrent()) throw new PanelRunControlError('session_changed');
};
const request = async (
path: string,
body?: object,
accepted: number[] = [],
) => {
assertCurrent();
let response: Response, value: any;
try {
response = await fetch(path, {
method: body ? 'POST' : 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
authorization: `Bearer ${qingLong3Credential()}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
value = await response.json();
} catch {
assertCurrent();
throw new PanelRunControlError('request_unavailable', Boolean(body));
}
assertCurrent();
if (!response.ok && !accepted.includes(response.status)) {
throw new PanelRunControlError(
typeof value?.code === 'string' ? value.code : 'request_unavailable',
Boolean(body) && response.status >= 500,
);
}
return { value, status: response.status };
};
const prepared = (
kind: 'start' | 'cancel',
target: string,
fields: object,
validate: (value: any) => boolean,
): PreparedRunAction => {
const mutationId = crypto.randomUUID();
const body = Object.freeze({ ...fields, mutationId });
const path =
kind === 'start'
? `${base}/tasks/${taskId}/runs`
: `${base}/runs/${target}/cancellation`;
return Object.freeze({
kind,
target,
mutationId,
async execute() {
assertCurrent();
if (writing) throw new PanelRunControlError('operation_pending');
writing = true;
try {
const { value } = await request(path, body);
if (!validate(value))
throw new PanelRunControlError('invalid_mutation_response', true);
return Object.freeze({
runId: value.runId as string,
status: value.status as string,
});
} finally {
writing = false;
}
},
});
};
return Object.freeze({
isCurrent,
dispose() {
disposed = true;
currentTask = null;
currentRun = null;
},
async readTask(): Promise<PanelTask> {
const { value } = await request(`${base}/tasks/${taskId}`);
const task = value?.task;
if (
!task ||
task.taskId !== taskId ||
typeof task.name !== 'string' ||
task.name.length > 512 ||
!Number.isSafeInteger(task.revision) ||
task.revision < 1 ||
!DIGEST.test(task.contentDigest) ||
typeof task.enabled !== 'boolean'
) {
throw new PanelRunControlError('invalid_task_response');
}
currentTask = Object.freeze({
taskId,
name: task.name,
revision: task.revision,
contentDigest: task.contentDigest,
enabled: task.enabled,
});
return currentTask;
},
async listRuns(after?: RunCursor): Promise<RunPage> {
const cursor = after ? cursorOf(after) : undefined;
// Canonical IDs contain no query separators; the HTTP parser rejects encoded colon aliases.
const query = cursor
? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}`
: '';
const { value } = await request(`${base}/runs?limit=64${query}`);
if (
!Array.isArray(value?.runs) ||
value.runs.length > 64 ||
typeof value.hasMore !== 'boolean' ||
value.hasMore !== Boolean(value.next)
)
throw new PanelRunControlError('invalid_run_page');
const runs: PanelRun[] = value.runs.map(runOf);
let previous = cursor;
for (const run of runs) {
const next = { createdAtMs: run.createdAtMs, runId: run.id };
if (previous && !follows(previous, next))
throw new PanelRunControlError('invalid_run_page');
previous = next;
}
const next = value.next ? cursorOf(value.next) : undefined;
if (
next &&
(!runs.length ||
next.runId !== previous?.runId ||
next.createdAtMs !== previous?.createdAtMs)
) {
throw new PanelRunControlError('invalid_run_page');
}
return {
runs: runs.filter((run) => run.taskId === taskId),
scanned: runs.length,
next,
};
},
async readRun(runId: string): Promise<PanelRun> {
if (!validId(runId))
throw new PanelRunControlError('invalid_run_identity');
const { value } = await request(`${base}/runs/${runId}`);
const run = runOf(value?.run);
if (
run.id !== runId ||
run.taskId !== taskId ||
value.run.projectId !== projectId
) {
throw new PanelRunControlError('invalid_run_identity');
}
currentRun = run;
return run;
},
prepareStart(task: PanelTask) {
assertCurrent();
if (task !== currentTask || !task.enabled)
throw new PanelRunControlError('task_not_ready');
return prepared(
'start',
taskId,
{
schema: 'qinglong/task-start@v1',
expectedRevision: task.revision,
expectedContentDigest: task.contentDigest,
},
(value) =>
value?.schema === 'qinglong/task-start@v1' &&
value.projectId === projectId &&
value.taskId === taskId &&
value.taskRevision === task.revision &&
value.taskContentDigest === task.contentDigest &&
validId(value.runId) &&
['accepted', 'existing'].includes(value.status),
);
},
prepareCancel(run: PanelRun) {
assertCurrent();
if (run !== currentRun || TERMINAL.has(run.status))
throw new PanelRunControlError('run_not_active');
return prepared(
'cancel',
run.id,
{ schema: 'qinglong/run-cancellation@v1' },
(value) =>
value?.schema === 'qinglong/run-cancellation@v1' &&
value.projectId === projectId &&
value.runId === run.id &&
['accepted', 'already_requested', 'already_terminal'].includes(
value.status,
),
);
},
async readLog(run: PanelRun): Promise<string> {
if (run !== currentRun)
throw new PanelRunControlError('invalid_run_identity');
if (!run.latestAttempt?.logAvailable)
return '当前运行尚无可读日志,请刷新运行状态后重试。';
const length = capabilities.limits.logChunkBytes;
const attemptId = run.latestAttempt.id;
const { value, status } = await request(
`${base}/runs/${run.id}/attempts/${attemptId}/log?offset=0&length=${length}`,
undefined,
[202, 410],
);
if (status === 202) return '日志尚未就绪。';
if (status === 410) return '日志已按保留策略清理。';
if (
value?.status !== 'available' ||
value.encoding !== 'base64' ||
value.runId !== run.id ||
value.attemptId !== attemptId ||
typeof value.content !== 'string' ||
value.content.length > Math.ceil(length / 3) * 4
) {
throw new PanelRunControlError('invalid_log_response');
}
const bytes = Uint8Array.from(atob(value.content), (c) =>
c.charCodeAt(0),
);
if (bytes.length > length)
throw new PanelRunControlError('invalid_log_response');
return `${new TextDecoder().decode(
bytes,
)}\n\n[本次仅显示首个至多 ${length} 字节;刷新不自动续读]`;
},
});
}
@@ -0,0 +1,384 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Button,
Descriptions,
Modal,
Space,
Table,
Typography,
} from 'antd';
import type { QingLong3Capabilities } from '@/utils/qinglong3';
import {
createPanelRunControl,
PanelRunControlError,
type PanelTask,
type PanelRun,
type PreparedRunAction,
type RunPage,
type RunCursor,
} from './runControl';
const terminal = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const labels: Record<string, string> = {
succeeded: '成功',
failed: '失败',
cancelled: '已取消',
timed_out: '已超时',
created: '已创建',
queued: '排队中',
dispatching: '派发中',
running: '运行中',
waiting_approval: '等待审批',
retry_wait: '等待重试',
lost: '执行状态待核对',
};
const errors: Record<string, string> = {
authentication_required: '凭据已失效,请重新登录。',
authorization_denied: '当前身份没有执行该操作的权限。',
task_start_fence_rejected:
'任务版本或权限在确认期间已改变。本次启动被拒绝,请刷新任务后重新确认。',
session_changed: '连接已改变,请关闭窗口并重新登录。',
task_not_ready: '请先读取当前任务,已停用的任务不能启动。',
run_not_active: '请选择并刷新一个尚未结束的运行。',
};
export default function QingLong3RunControlModal({
cron,
capabilities,
onClose,
}: {
cron: any;
capabilities: Readonly<QingLong3Capabilities>;
onClose(): void;
}) {
const clientRef = useRef<ReturnType<typeof createPanelRunControl>>();
const busyRef = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [task, setTask] = useState<PanelTask>();
const [page, setPage] = useState<RunPage>();
const [cursor, setCursor] = useState<RunCursor>();
const [run, setRun] = useState<PanelRun>();
const [log, setLog] = useState('');
const [prepared, setPrepared] = useState<PreparedRunAction>();
const [uncertain, setUncertain] = useState(false);
const work = async (
action: (client: ReturnType<typeof createPanelRunControl>) => Promise<void>,
) => {
const client = clientRef.current;
if (!client || busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError('');
try {
await action(client);
} catch (caught) {
if (clientRef.current !== client) return;
const failure = caught instanceof PanelRunControlError ? caught : null;
if (!client.isCurrent()) {
setTask(undefined);
setRun(undefined);
setPage(undefined);
setLog('');
setPrepared(undefined);
setError(errors.session_changed);
} else {
setError(
failure?.uncertain
? '服务端可能已收到请求。请重试同一请求以核对结果;不要新建另一份启动请求。'
: errors[failure?.code || ''] ||
'读取或操作失败,请检查服务与权限后手动重试。',
);
setUncertain(Boolean(failure?.uncertain));
}
} finally {
if (clientRef.current === client) {
busyRef.current = false;
setBusy(false);
}
}
};
const loadRuns = async (
client: ReturnType<typeof createPanelRunControl>,
after?: RunCursor,
) => {
const result = await client.listRuns(after);
if (!client.isCurrent()) return;
setPage(result);
setCursor(after);
setRun(undefined);
setLog('');
};
const selectRun = async (
client: ReturnType<typeof createPanelRunControl>,
id: string,
) => {
const result = await client.readRun(id);
if (client.isCurrent()) {
setRun(result);
setLog('');
}
};
useEffect(() => {
let client: ReturnType<typeof createPanelRunControl>;
try {
client = createPanelRunControl(cron, capabilities);
} catch {
setError('当前服务未开放执行管理,或条目标识无效。');
return;
}
clientRef.current = client;
void work(async () => {
const current = await client.readTask();
if (client.isCurrent()) setTask(current);
await loadRuns(client);
});
return () => {
client.dispose();
clientRef.current = undefined;
busyRef.current = false;
};
}, [cron, capabilities]);
const prepare = (kind: 'start' | 'cancel') =>
void work(async (client) => {
const action =
kind === 'start'
? client.prepareStart(task!)
: client.prepareCancel(run!);
setPrepared(action);
setNotice('');
setUncertain(false);
});
const execute = () =>
void work(async (client) => {
if (!prepared) return;
const receipt = await prepared.execute();
if (!client.isCurrent()) return;
setPrepared(undefined);
setUncertain(false);
setNotice(
prepared.kind === 'start'
? `运行已登记:${receipt.runId}。排队不代表已经执行成功。`
: `取消请求结果:${receipt.status}。只有运行进入终态才表示已结束。`,
);
await selectRun(client, receipt.runId);
});
const close = () => {
if (busy || uncertain) {
Modal.confirm({
title: '关闭执行管理?',
content:
'关闭窗口不会撤销已发送的请求。再次打开时请先核对运行记录,避免重复启动。',
onOk: onClose,
});
} else onClose();
};
const blocked = busy || Boolean(prepared);
return (
<Modal
open
title="执行管理 · QingLong 3.0"
width={860}
onCancel={close}
footer={<Button onClick={close}></Button>}
destroyOnClose
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="手动运行使用 Task 当前版本,不修改定时计划。"
description="取消针对选中的 Run;没有后台轮询,请手动刷新确认状态。实际操作仍受项目权限控制。"
/>
{error && <Alert type="error" showIcon message={error} />}
{notice && <Alert type="success" showIcon message={notice} />}
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="任务">
{task?.name || cron.name} ·{' '}
<Typography.Text code>{cron.ql3.taskId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="定时绑定版本">
{cron.ql3.taskRevision}
</Descriptions.Item>
<Descriptions.Item label="本次运行版本">
{task
? `${task.revision}${task.enabled ? '' : '(已停用)'}`
: '未读取'}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button
disabled={blocked}
onClick={() =>
void work(async (client) => {
const current = await client.readTask();
if (client.isCurrent()) setTask(current);
})
}
>
</Button>
<Button
type="primary"
disabled={blocked || !task?.enabled}
onClick={() => prepare('start')}
>
</Button>
</Space>
{prepared && (
<Alert
type="warning"
showIcon
message={
prepared.kind === 'start'
? `确认运行 Task ${prepared.target} · revision ${task?.revision}`
: `确认请求取消 Run ${prepared.target}`
}
description={
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text type="secondary">
ID{prepared.mutationId} ID
</Typography.Text>
<Space wrap>
<Button
danger={prepared.kind === 'cancel'}
type="primary"
loading={busy}
onClick={execute}
>
{uncertain
? '重试同一请求'
: prepared.kind === 'start'
? '确认运行一次'
: '确认请求取消'}
</Button>
<Button
disabled={busy || uncertain}
onClick={() => setPrepared(undefined)}
>
</Button>
</Space>
</Space>
}
/>
)}
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
64
</Typography.Text>
<Table<PanelRun>
size="small"
rowKey="id"
pagination={false}
loading={busy}
dataSource={page?.runs || []}
scroll={{ x: 600 }}
locale={{
emptyText: '当前窗口没有匹配运行,可继续查找或回到最近记录。',
}}
columns={[
{
title: 'Run ID',
dataIndex: 'id',
render: (id: string) => (
<Button
type="link"
disabled={blocked}
style={{ padding: 0, maxWidth: '100%' }}
onClick={() => void work((client) => selectRun(client, id))}
>
{id}
</Button>
),
},
{ title: '版本', dataIndex: 'taskRevision' },
{
title: '状态',
dataIndex: 'status',
render: (status: string) => labels[status],
},
]}
/>
<Space wrap>
<Button
disabled={blocked}
onClick={() => void work((client) => loadRuns(client, cursor))}
>
</Button>
<Button
disabled={blocked || !cursor}
onClick={() => void work((client) => loadRuns(client))}
>
</Button>
<Button
disabled={blocked || !page?.next}
onClick={() => void work((client) => loadRuns(client, page?.next))}
>
</Button>
</Space>
{run && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="已选 Run">
<Typography.Text code>{run.id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
{labels[run.status]}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button
disabled={blocked}
onClick={() => void work((client) => selectRun(client, run.id))}
>
</Button>
<Button
disabled={blocked}
onClick={() =>
void work(async (client) => {
const text = await client.readLog(run);
if (client.isCurrent()) setLog(text);
})
}
>
</Button>
<Button
danger
disabled={blocked || terminal.has(run.status)}
onClick={() => prepare('cancel')}
>
</Button>
</Space>
</>
)}
{log && (
<pre
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxHeight: 320,
overflow: 'auto',
}}
>
{log}
</pre>
)}
</Space>
</Modal>
);
}
+26 -1
View File
@@ -53,6 +53,7 @@ import { getScheduleType } from './const';
import CronDetailModal from './detail';
import './index.less';
import CronLogModal from './logModal';
import QingLong3RunControlModal from '@/components/qinglong3/runControlModal';
import CronModal, { CronLabelModal } from './modal';
import {
CrontabStatus,
@@ -305,6 +306,16 @@ const Crontab = () => {
const isPc = !isPhone;
return (
<Space size="middle">
{qingLong3?.panel.runControl === 'task_run_v1' && (
<a
onClick={(e) => {
e.stopPropagation();
setRunControlCron(record);
}}
>
</a>
)}
{!qingLong3ReadOnly && record.status === CrontabStatus.idle && (
<a
onClick={(e) => {
@@ -350,6 +361,7 @@ const Crontab = () => {
const [searchText, setSearchText] = useState('');
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [logCron, setLogCron] = useState<any>();
const [runControlCron, setRunControlCron] = useState<any>();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [pageConf, setPageConf] = useState<{
page: number;
@@ -992,7 +1004,13 @@ const Crontab = () => {
title={intl.get('定时任务')}
extra={
qingLong3ReadOnly
? [<Tag key="ql3-read-only">QingLong 3.0 · </Tag>]
? [
<Tag key="ql3-read-only">
{qingLong3?.panel.runControl === 'task_run_v1'
? 'QingLong 3.0 · 定时只读 / 执行可管理'
: 'QingLong 3.0 · 有界只读'}
</Tag>,
]
: [
<Search
key="search"
@@ -1145,6 +1163,13 @@ const Crontab = () => {
qingLong3={qingLong3}
/>
)}
{runControlCron && qingLong3?.panel.runControl === 'task_run_v1' && (
<QingLong3RunControlModal
cron={runControlCron}
capabilities={qingLong3}
onClose={() => setRunControlCron(undefined)}
/>
)}
{!qingLong3ReadOnly && isModalVisible && (
<CronModal handleCancel={handleCancel} cron={editedCron} />
)}
+10
View File
@@ -15,6 +15,7 @@ export interface QingLong3Capabilities {
readonly panel: Readonly<{
bootstrap: true;
cronList: 'bounded_read_only';
runControl?: 'task_run_v1';
legacyMutations: false;
legacyLogin: false;
subscriptions: false;
@@ -34,6 +35,11 @@ const CREDENTIAL_PATTERN =
let capabilities: Readonly<QingLong3Capabilities> | null = null;
let credential: string | null = null;
let session: Readonly<object> = Object.freeze({});
export function qingLong3Session(): Readonly<object> {
return session;
}
function validCapabilities(value: any): value is QingLong3Capabilities {
const profile = value?.deployment?.profile;
@@ -50,6 +56,8 @@ function validCapabilities(value: any): value is QingLong3Capabilities {
value?.authentication?.loginEndpoint === null &&
value?.panel?.bootstrap === true &&
value?.panel?.cronList === 'bounded_read_only' &&
(value?.panel?.runControl === undefined ||
value.panel.runControl === 'task_run_v1') &&
value?.panel?.legacyMutations === false &&
value?.panel?.legacyLogin === false &&
value?.panel?.subscriptions === false &&
@@ -96,6 +104,7 @@ export function qingLong3Capabilities(): Readonly<QingLong3Capabilities> | null
export function setQingLong3Credential(value: string): boolean {
if (!CREDENTIAL_PATTERN.test(value)) return false;
session = Object.freeze({});
credential = value;
return true;
}
@@ -105,6 +114,7 @@ export function qingLong3Credential(): string | null {
}
export function clearQingLong3Credential(): void {
session = Object.freeze({});
credential = null;
}