feat(ql3): add request-scoped console task creation

This commit is contained in:
whyour
2026-08-29 03:51:35 +08:00
parent 53b7072370
commit 884912d1c8
32 changed files with 2485 additions and 56 deletions
@@ -35,7 +35,8 @@ body {
}
button,
input {
input,
textarea {
font: inherit;
}
@@ -45,6 +46,7 @@ button {
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--amber);
outline-offset: 3px;
@@ -321,6 +323,14 @@ input:focus-visible,
line-height: 1.6;
}
.masthead-actions {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.refresh-button {
flex: 0 0 auto;
color: var(--deep);
@@ -700,6 +710,155 @@ input:focus-visible,
box-shadow: var(--shadow);
}
.task-editor-dialog,
.presence-dialog {
padding: 0;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 0;
background: var(--paper);
box-shadow: var(--shadow);
}
.task-editor-dialog {
width: min(720px, calc(100vw - 32px));
}
.presence-dialog {
width: min(520px, calc(100vw - 32px));
}
.task-editor-dialog::backdrop,
.presence-dialog::backdrop {
background: rgba(11, 31, 36, 0.76);
}
.task-editor-dialog form,
.presence-dialog form {
padding: clamp(22px, 4vw, 34px);
}
.dialog-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.task-editor-dialog h2,
.presence-dialog h2 {
margin: 0;
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
font-size: 34px;
line-height: 1;
}
.editor-intro,
.presence-dialog form > p:not(.eyebrow),
.editor-note,
.presence-expiry {
color: var(--muted);
line-height: 1.6;
}
.editor-grid {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.editor-grid label,
.presence-input {
display: grid;
gap: 7px;
}
.editor-grid label > span,
.presence-input > span {
color: var(--muted);
font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-grid input:not([type='checkbox']),
.editor-grid textarea,
.presence-input input {
width: 100%;
padding: 11px 12px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 0;
background: var(--white);
font: 500 13px/1.45 ui-monospace, 'SFMono-Regular', Consolas, monospace;
}
.editor-grid textarea {
resize: vertical;
}
.editor-wide {
grid-column: 1 / -1;
}
.editor-check {
grid-column: 1 / -1;
grid-template-columns: auto 1fr;
align-items: center;
justify-content: start;
}
.editor-check input {
width: 18px;
height: 18px;
accent-color: var(--signal);
}
.editor-note {
margin: 18px 0 0;
padding-left: 12px;
border-left: 3px solid var(--amber);
font-size: 11px;
}
.proof-ticket {
position: relative;
margin: 22px 0;
padding: 18px 16px 18px 22px;
display: block;
color: var(--white);
border-left: 6px solid var(--signal);
background: var(--deep);
font: 650 12px/1.5 ui-monospace, 'SFMono-Regular', Consolas, monospace;
overflow-wrap: anywhere;
}
.proof-ticket::after {
content: 'HOST FILE';
position: absolute;
top: 8px;
right: 10px;
color: rgba(255, 255, 255, 0.35);
font-size: 8px;
letter-spacing: 0.12em;
}
.presence-expiry {
margin-bottom: 0;
font-size: 11px;
}
.presence-error {
margin: 14px 0 0;
padding: 10px 12px;
color: var(--danger);
border-left: 3px solid var(--danger);
background: rgba(186, 74, 67, 0.08);
font-size: 12px;
line-height: 1.5;
}
.confirmation-dialog::backdrop {
background: rgba(11, 31, 36, 0.7);
}
@@ -813,6 +972,11 @@ input:focus-visible,
flex-direction: column;
}
.masthead-actions {
width: 100%;
justify-content: flex-start;
}
.masthead h2 {
font-size: 40px;
}
@@ -836,6 +1000,15 @@ input:focus-visible,
.record-side {
justify-items: start;
}
.editor-grid {
grid-template-columns: 1fr;
}
.editor-wide,
.editor-check {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -2,8 +2,10 @@
'use strict';
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const PRESENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({
@@ -30,6 +32,16 @@
run_step_list_unavailable: 'Workflow Step 暂时不可用。',
task_start_fence_rejected:
'任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。',
local_presence_rejected:
'本机证明不匹配或已过期。请核对文件;过期后关闭窗口并重新保存。',
local_presence_unavailable:
'暂时无法生成本机证明。请检查部署数据目录权限。',
strong_authentication_required:
'当前凭据不能执行管理操作;请使用本机 User API Credential。',
task_definition_fence_rejected:
'Task 或授权在确认期间发生变化。请刷新后重新创建。',
invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。',
task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。',
run_cancellation_fence_rejected:
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
@@ -49,10 +61,29 @@
title: document.getElementById('section-title'),
description: document.getElementById('section-description'),
refresh: document.getElementById('refresh-button'),
createTask: document.getElementById('create-task-button'),
dialog: document.getElementById('confirmation-dialog'),
dialogTitle: document.getElementById('confirmation-title'),
dialogCopy: document.getElementById('confirmation-copy'),
dialogAccept: document.getElementById('confirmation-accept'),
taskEditor: document.getElementById('task-editor-dialog'),
taskEditorForm: document.getElementById('task-editor-form'),
taskEditorClose: document.getElementById('task-editor-close'),
taskEditorSave: document.getElementById('task-editor-save'),
taskId: document.getElementById('task-id-input'),
taskName: document.getElementById('task-name-input'),
taskDescription: document.getElementById('task-description-input'),
taskCommand: document.getElementById('task-command-input'),
taskArgs: document.getElementById('task-args-input'),
taskEnabled: document.getElementById('task-enabled-input'),
presenceDialog: document.getElementById('presence-dialog'),
presenceForm: document.getElementById('presence-form'),
presenceFile: document.getElementById('presence-file'),
presenceProof: document.getElementById('presence-proof-input'),
presenceExpiry: document.getElementById('presence-expiry'),
presenceError: document.getElementById('presence-error'),
presenceCancel: document.getElementById('presence-cancel'),
presenceSubmit: document.getElementById('presence-submit'),
toast: document.getElementById('toast'),
});
@@ -62,6 +93,7 @@
view: 'tasks',
selectedId: null,
pendingAction: null,
pendingTaskMutation: null,
toastTimer: null,
};
@@ -169,6 +201,9 @@
body = JSON.stringify(options.body);
headers['content-type'] = 'application/json';
}
if (options.presence !== undefined) {
headers['x-qinglong-local-presence'] = options.presence;
}
let response;
try {
response = await fetch(path, {
@@ -193,7 +228,7 @@
response.headers.get('x-request-id'),
);
}
if (!response.ok) {
if (!response.ok && response.status !== options.acceptStatus) {
throw new ConsoleRequestError(
typeof value.code === 'string' ? value.code : 'request_unavailable',
response.status,
@@ -310,6 +345,148 @@
nodes.dialog.showModal();
}
function openTaskEditor() {
nodes.taskEditorForm.reset();
nodes.taskCommand.value = '/bin/echo';
nodes.taskEnabled.checked = true;
nodes.taskEditor.returnValue = '';
nodes.taskEditor.showModal();
nodes.taskId.focus();
}
function taskDraft() {
const taskId = nodes.taskId.value.trim();
const name = nodes.taskName.value.trim();
const description = nodes.taskDescription.value.trim();
const file = nodes.taskCommand.value.trim();
const args = nodes.taskArgs.value
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (!TASK_PATTERN.test(taskId)) {
throw new TypeError('Task ID 格式无效。');
}
if (!name || !file || args.length > 128) {
throw new TypeError('名称、命令或参数数量无效。');
}
return Object.freeze({
taskId,
body: Object.freeze({
expectedRevision: null,
mutationId: newMutationId(),
name,
...(description ? { description } : {}),
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({ kind: 'argv', file, args }),
}),
}),
labels: Object.freeze({ 'qinglong.source': 'local-console' }),
enabled: nodes.taskEnabled.checked,
occurredAtMs: Date.now(),
}),
});
}
function showPresenceChallenge(mutation, challenge) {
if (
challenge?.code !== 'local_presence_required' ||
typeof challenge.proofFileName !== 'string' ||
!/^[0-9a-f-]{36}\.json$/u.test(challenge.proofFileName) ||
!Number.isSafeInteger(challenge.expiresAtMs)
) {
throw new ConsoleRequestError('response_unavailable', 503, null);
}
state.pendingTaskMutation = Object.freeze({ mutation, challenge });
nodes.presenceFile.textContent = `console-presence/${challenge.proofFileName}`;
nodes.presenceExpiry.textContent = `证明将在 ${formatTime(
challenge.expiresAtMs,
)} 失效;内容改变后必须重新生成。`;
nodes.presenceProof.value = '';
nodes.presenceError.textContent = '';
nodes.presenceError.hidden = true;
nodes.taskEditor.close();
nodes.presenceDialog.returnValue = '';
nodes.presenceDialog.showModal();
nodes.presenceProof.focus();
}
async function saveTaskDraft() {
let mutation;
try {
mutation = taskDraft();
} catch (error) {
showToast(
error instanceof Error ? error.message : 'Task 定义无效。',
'error',
);
return;
}
nodes.taskEditorSave.disabled = true;
try {
const value = await api(
`/api/v3/projects/${state.project}/tasks/${mutation.taskId}`,
{
method: 'PUT',
body: mutation.body,
acceptStatus: 428,
},
);
if (value.code === 'local_presence_required') {
showPresenceChallenge(mutation, value);
return;
}
throw new ConsoleRequestError('response_unavailable', 503, null);
} catch (error) {
showToast(describeError(error), 'error');
} finally {
nodes.taskEditorSave.disabled = false;
}
}
async function completeTaskMutation() {
const pending = state.pendingTaskMutation;
const proof = nodes.presenceProof.value.trim();
if (!pending || !PRESENCE_PATTERN.test(proof)) {
nodes.presenceError.textContent =
'proof 格式无效。请完整复制私有文件中的 proof 字段。';
nodes.presenceError.hidden = false;
nodes.presenceProof.focus();
return;
}
nodes.presenceSubmit.disabled = true;
nodes.presenceError.hidden = true;
try {
const value = await api(
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
{
method: 'PUT',
body: pending.mutation.body,
presence: proof,
},
);
state.pendingTaskMutation = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
showToast(
value.status === 'existing'
? '已找到同一 Task 请求。'
: 'Task 已创建。',
);
state.selectedId = pending.mutation.taskId;
await refresh();
await selectTask(pending.mutation.taskId);
} catch (error) {
nodes.presenceError.textContent = describeError(error);
nodes.presenceError.hidden = false;
nodes.presenceProof.select();
} finally {
nodes.presenceSubmit.disabled = false;
}
}
async function renderTasks() {
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
@@ -595,7 +772,11 @@
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
};
section.append(
element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
element(
'p',
'run-log-placeholder',
labels[logView.status] || labels.unavailable,
),
);
return section;
}
@@ -702,11 +883,13 @@
else button.removeAttribute('aria-current');
}
if (state.view === 'tasks') {
nodes.createTask.hidden = false;
nodes.kicker.textContent = 'Project task authority';
nodes.title.textContent = '任务调度台';
nodes.description.textContent =
'查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。';
'创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。';
} else {
nodes.createTask.hidden = true;
nodes.kicker.textContent = 'Durable run evidence';
nodes.title.textContent = '运行事实账本';
nodes.description.textContent =
@@ -750,6 +933,9 @@
state.token = null;
state.selectedId = null;
state.pendingAction = null;
state.pendingTaskMutation = null;
if (nodes.taskEditor.open) nodes.taskEditor.close();
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
nodes.token.value = '';
nodes.token.disabled = false;
nodes.project.disabled = false;
@@ -757,6 +943,7 @@
nodes.disconnect.hidden = true;
nodes.nav.hidden = true;
nodes.refresh.hidden = true;
nodes.createTask.hidden = true;
setConnection('idle', '等待凭据');
nodes.kicker.textContent = 'Connection gate';
nodes.title.textContent = '先建立一条本机连接';
@@ -793,6 +980,23 @@
nodes.disconnect.addEventListener('click', disconnect);
nodes.refresh.addEventListener('click', refresh);
nodes.createTask.addEventListener('click', openTaskEditor);
nodes.taskEditorClose.addEventListener('click', () =>
nodes.taskEditor.close(),
);
nodes.taskEditorForm.addEventListener('submit', async (event) => {
event.preventDefault();
await saveTaskDraft();
});
nodes.presenceCancel.addEventListener('click', () => {
state.pendingTaskMutation = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
});
nodes.presenceForm.addEventListener('submit', async (event) => {
event.preventDefault();
await completeTaskMutation();
});
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
button.addEventListener('click', () => {
@@ -820,7 +1024,10 @@
event.ctrlKey ||
event.altKey ||
event.target instanceof HTMLInputElement ||
nodes.dialog.open
event.target instanceof HTMLTextAreaElement ||
nodes.dialog.open ||
nodes.taskEditor.open ||
nodes.presenceDialog.open
) {
return;
}
@@ -87,9 +87,14 @@
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
</p>
</div>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
<div class="masthead-actions">
<button class="action-button" id="create-task-button" type="button" hidden>
<span aria-hidden="true"></span> 创建任务
</button>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
</div>
</header>
<div class="workspace-grid">
@@ -125,6 +130,89 @@
</form>
</dialog>
<dialog id="task-editor-dialog" class="task-editor-dialog">
<form id="task-editor-form" autocomplete="off">
<div class="dialog-heading">
<div>
<p class="eyebrow">Task authoring</p>
<h2>创建命令任务</h2>
</div>
<button class="quiet-button" id="task-editor-close" type="button">关闭</button>
</div>
<p class="editor-intro">
定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。
</p>
<div class="editor-grid">
<label>
<span>Task ID</span>
<input id="task-id-input" maxlength="128" spellcheck="false" required />
</label>
<label>
<span>名称</span>
<input id="task-name-input" maxlength="160" required />
</label>
<label class="editor-wide">
<span>说明(可选)</span>
<input id="task-description-input" maxlength="1000" />
</label>
<label class="editor-wide">
<span>可执行文件</span>
<input
id="task-command-input"
value="/bin/echo"
maxlength="4096"
spellcheck="false"
required
/>
</label>
<label class="editor-wide">
<span>参数 · 每行一个</span>
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
</label>
<label class="editor-check">
<input id="task-enabled-input" type="checkbox" checked />
<span>创建后允许运行</span>
</label>
</div>
<p class="editor-note">
Alpha 当前从 Console 创建 <code>qinglong/command@v1</code>;高级 Task schema 仍使用受信任管理入口。
</p>
<div class="dialog-actions">
<button class="primary-button" id="task-editor-save" type="submit">
保存并生成本机证明
</button>
</div>
</form>
</dialog>
<dialog id="presence-dialog" class="presence-dialog">
<form id="presence-form" autocomplete="off">
<p class="eyebrow">Local presence · 02:00</p>
<h2>从部署设备取得证明</h2>
<p>
使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。
</p>
<code class="proof-ticket" id="presence-file"></code>
<label class="presence-input">
<span>文件中的 proof</span>
<input
id="presence-proof-input"
type="password"
maxlength="128"
spellcheck="false"
autocomplete="off"
required
/>
</label>
<p class="presence-expiry" id="presence-expiry"></p>
<p class="presence-error" id="presence-error" role="alert" hidden></p>
<div class="dialog-actions">
<button class="quiet-button" id="presence-cancel" type="button">取消</button>
<button class="primary-button" id="presence-submit" type="submit">验证并创建</button>
</div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>