mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): manage canonical task runs from the existing panel
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user