feat(ql3): paginate native task trigger and run ledgers

This commit is contained in:
whyour
2026-09-04 03:23:21 +08:00
parent 8e1f1ed7ae
commit ce8c3a7d2a
6 changed files with 666 additions and 57 deletions
+205 -57
View File
@@ -916,8 +916,16 @@
}
}
async function renderSecrets() {
const value = await loadSecretCatalog();
async function renderSecrets(request) {
const response = await api(
`/api/v3/projects/${request.project}/secrets?limit=64`,
);
if (!request.isCurrent()) return;
state.secretCatalog = normalizeSecretMetadata(response);
const value = {
secrets: state.secretCatalog,
truncated: response.truncated === true,
};
if (value.secrets.length === 0) {
empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。');
return;
@@ -1221,11 +1229,122 @@
}
}
async function renderTasks() {
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
function ledgerCursor(view, value) {
const field =
view === 'runs' ? 'runId' : view === 'tasks' ? 'taskId' : 'triggerId';
const keys = view === 'runs' ? [field, 'createdAtMs'] : [field];
if (
!value ||
Object.keys(value).length !== keys.length ||
!keys.every((key) => Object.hasOwn(value, key)) ||
typeof value[field] !== 'string' ||
!TASK_PATTERN.test(value[field]) ||
(view === 'runs' &&
(!Number.isSafeInteger(value.createdAtMs) || value.createdAtMs < 0))
) {
throw new TypeError('列表分页边界无效,请回到首页重新读取。');
}
return view === 'runs'
? { runId: value.runId, createdAtMs: value.createdAtMs }
: { [field]: value[field] };
}
function ledgerCursorFollows(view, cursor, previous) {
if (!previous) return true;
if (view === 'runs')
return (
cursor.createdAtMs < previous.createdAtMs ||
(cursor.createdAtMs === previous.createdAtMs &&
cursor.runId < previous.runId)
);
const field = view === 'tasks' ? 'taskId' : 'triggerId';
return cursor[field] > previous[field];
}
async function readLedgerPage(request) {
const { view, project, after } = request;
let query = 'limit=64';
if (after) {
const cursor = ledgerCursor(view, after);
// The HTTP contract accepts canonical ASCII IDs, not percent-encoded aliases.
query +=
view === 'runs'
? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}`
: view === 'tasks'
? `&after_task_id=${cursor.taskId}`
: `&after_trigger_id=${cursor.triggerId}`;
}
const value = await api(`/api/v3/projects/${project}/${view}?${query}`);
if (!request.isCurrent()) return null;
const rows = value[view];
const hasMore = view === 'triggers' ? value.truncated : value.hasMore;
if (
!Array.isArray(rows) ||
rows.length > 64 ||
typeof hasMore !== 'boolean'
) {
throw new TypeError('列表响应无效,请重新读取。');
}
let boundary = after;
for (const row of rows) {
const cursor = ledgerCursor(
view,
view === 'runs'
? { runId: row?.id, createdAtMs: row?.createdAtMs }
: view === 'tasks'
? { taskId: row?.taskId }
: { triggerId: row?.triggerId },
);
if (!ledgerCursorFollows(view, cursor, boundary)) {
throw new TypeError('列表顺序或分页边界发生异常,请回到首页。');
}
boundary = cursor;
}
let next = null;
if (hasMore) {
next = ledgerCursor(view, value.next);
if (!rows.length || JSON.stringify(next) !== JSON.stringify(boundary)) {
throw new TypeError('列表继续边界不匹配,请回到首页。');
}
} else if (value.next != null) {
throw new TypeError('列表终点携带异常继续边界,请回到首页。');
}
return { rows, next };
}
function ledgerNavigation(request, next) {
const footer = element('section');
const actions = element('div', 'detail-actions');
const load = (after) => {
if (!footer.isConnected || !request.isCurrent()) return;
return refresh({ pageCursor: after });
};
actions.append(actionButton('刷新当前页', () => load(request.after)));
if (request.after)
actions.append(actionButton('回到首页', () => load(null)));
if (next) actions.append(actionButton('下一页', () => load(next)));
footer.append(
actions,
element(
'p',
'privacy-note',
'每次最多 64 条,翻页替换当前窗口。列表不是固定快照;顶部刷新或切换栏目从首页重新读取。',
),
);
return footer;
}
async function renderTasks(request) {
const page = await readLedgerPage(request);
if (!page) return;
const tasks = page.rows;
if (tasks.length === 0) {
empty('还没有可见任务。请先通过受信任的管理入口发布 Task。');
empty(
request.after
? '当前窗口没有任务,可回到首页重新读取。'
: '还没有可见任务。选择“创建任务”开始。',
);
nodes.ledger.append(ledgerNavigation(request, page.next));
return;
}
const fragment = document.createDocumentFragment();
@@ -1260,20 +1379,23 @@
list.append(button);
}
fragment.append(list);
if (value.hasMore) {
fragment.append(
element(
'p',
'privacy-note',
'当前只展示前 64 条;完整分页将在后续 Console 切片开放。',
),
);
}
fragment.append(ledgerNavigation(request, page.next));
replace(nodes.ledger, fragment);
}
async function selectTask(taskId) {
state.selectedId = taskId;
const selection = {};
const project = state.project;
const listRequest = state.listRequest;
state.detailSelection = selection;
const isCurrent = () =>
state.detailSelection === selection &&
state.listRequest === listRequest &&
state.selectedId === taskId &&
state.view === 'tasks' &&
state.project === project &&
Boolean(state.token);
const selected = nodes.ledger.querySelectorAll('.record');
for (const row of selected) {
if (row.dataset.identity === taskId) {
@@ -1286,12 +1408,13 @@
loadingBox.append(element('span'));
replace(nodes.detail, loadingBox);
try {
const value = await api(
`/api/v3/projects/${state.project}/tasks/${taskId}`,
);
const value = await api(`/api/v3/projects/${project}/tasks/${taskId}`);
if (!isCurrent()) return;
if (value.task?.taskId !== taskId)
throw new Error('Task identity mismatch');
renderTaskDetail(value.task);
} catch (error) {
detailEmpty(describeError(error));
if (isCurrent()) detailEmpty(describeError(error));
}
}
@@ -1360,13 +1483,17 @@
}
}
async function renderTriggers() {
const value = await api(
`/api/v3/projects/${state.project}/triggers?limit=64`,
);
const triggers = Array.isArray(value.triggers) ? value.triggers : [];
async function renderTriggers(request) {
const page = await readLedgerPage(request);
if (!page) return;
const triggers = page.rows;
if (triggers.length === 0) {
empty('还没有定时触发器。创建后,现有本地调度器会按 cron 自动生成 Run。');
empty(
request.after
? '当前窗口没有定时,可回到首页重新读取。'
: '还没有定时触发器。创建后,本地调度器会按 cron 自动生成 Run。',
);
nodes.ledger.append(ledgerNavigation(request, page.next));
return;
}
const fragment = document.createDocumentFragment();
@@ -1404,20 +1531,23 @@
list.append(button);
}
fragment.append(list);
if (value.truncated) {
fragment.append(
element(
'p',
'privacy-note',
'当前只展示前 64 条;使用 API 可继续读取下一页。',
),
);
}
fragment.append(ledgerNavigation(request, page.next));
replace(nodes.ledger, fragment);
}
async function selectTrigger(triggerId) {
state.selectedId = triggerId;
const selection = {};
const project = state.project;
const listRequest = state.listRequest;
state.detailSelection = selection;
const isCurrent = () =>
state.detailSelection === selection &&
state.listRequest === listRequest &&
state.selectedId === triggerId &&
state.view === 'triggers' &&
state.project === project &&
Boolean(state.token);
for (const row of nodes.ledger.querySelectorAll('.record')) {
if (row.dataset.identity === triggerId) {
row.setAttribute('aria-current', 'true');
@@ -1430,11 +1560,14 @@
replace(nodes.detail, loadingBox);
try {
const value = await api(
`/api/v3/projects/${state.project}/triggers/${triggerId}`,
`/api/v3/projects/${project}/triggers/${triggerId}`,
);
if (!isCurrent()) return;
if (value.trigger?.triggerId !== triggerId)
throw new Error('Trigger identity mismatch');
renderTriggerDetail(value.trigger);
} catch (error) {
detailEmpty(describeError(error));
if (isCurrent()) detailEmpty(describeError(error));
}
}
@@ -1480,11 +1613,17 @@
replace(nodes.detail, fragment);
}
async function renderRuns() {
const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`);
const runs = Array.isArray(value.runs) ? value.runs : [];
async function renderRuns(request) {
const page = await readLedgerPage(request);
if (!page) return;
const runs = page.rows;
if (runs.length === 0) {
empty('还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。');
empty(
request.after
? '当前窗口没有运行,可回到首页重新读取。'
: '还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。',
);
nodes.ledger.append(ledgerNavigation(request, page.next));
return;
}
const fragment = document.createDocumentFragment();
@@ -1513,15 +1652,7 @@
list.append(button);
}
fragment.append(list);
if (value.hasMore) {
fragment.append(
element(
'p',
'privacy-note',
'当前只展示最近 64 条;使用 API 可继续读取下一页。',
),
);
}
fragment.append(ledgerNavigation(request, page.next));
replace(nodes.ledger, fragment);
}
@@ -1856,19 +1987,36 @@
}
}
async function refresh() {
async function refresh(options = {}) {
if (!state.token) return;
state.selectedId = null;
detailEmpty();
const request = {
project: state.project,
view: state.view,
after: options.pageCursor ?? null,
};
state.listRequest = request;
request.isCurrent = () =>
state.listRequest === request &&
state.project === request.project &&
state.view === request.view &&
Boolean(state.token);
loading();
try {
if (state.view === 'tasks') await renderTasks();
else if (state.view === 'triggers') await renderTriggers();
else if (state.view === 'secrets') await renderSecrets();
else await renderRuns();
setConnection('connected', `${state.project} · 已连接`);
if (request.view === 'tasks') await renderTasks(request);
else if (request.view === 'triggers') await renderTriggers(request);
else if (request.view === 'secrets') await renderSecrets(request);
else await renderRuns(request);
if (request.isCurrent())
setConnection('connected', `${request.project} · 已连接`);
} catch (error) {
errorState(error);
if (request.isCurrent()) {
errorState(error);
if (request.after) nodes.ledger.append(ledgerNavigation(request, null));
}
} finally {
setBusy(false);
if (request.isCurrent()) setBusy(false);
}
}
@@ -0,0 +1,426 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
// A minimal DOM; all navigation, requests and validation execute the shipped JS.
class Element {
constructor(tag = 'div') {
this.tag = tag;
this.children = [];
this.dataset = {};
this.listeners = {};
this.isConnected = true;
}
set textContent(value) {
this.text = value;
this.replaceChildren();
}
get textContent() {
return (this.text || '') + this.children.map((x) => x.textContent).join('');
}
append(...children) {
for (const child of children) {
if (child.tag === 'fragment') this.append(...child.children);
else {
child.parent = this;
this.children.push(child);
}
}
}
detach() {
this.isConnected = false;
this.children.forEach((child) => child.detach());
}
replaceChildren(...children) {
this.children.forEach((child) => child.detach());
this.children = [];
this.append(...children);
}
setAttribute(name, value) {
this[name] = value;
}
removeAttribute(name) {
delete this[name];
}
addEventListener(name, handler) {
this.listeners[name] = handler;
}
querySelector() {
return new Element();
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [
...(selector === child.tag || selector === `.${child.className}`
? [child]
: []),
...child.querySelectorAll(selector),
]);
}
focus() {}
}
function fixture(view, response) {
const nodes = new Map();
const calls = [];
const context = vm.createContext({
TextDecoder,
Uint8Array,
URLSearchParams,
Intl,
console,
window: { atob },
document: {
getElementById(id) {
if (!nodes.has(id)) nodes.set(id, new Element());
return nodes.get(id);
},
querySelector: () => new Element(),
createElement: (tag) => new Element(tag),
createDocumentFragment: () => new Element('fragment'),
addEventListener() {},
},
fetch: async (url, options) => {
calls.push({ url, options });
const body = await response(url, calls.length);
return {
ok: !body.httpStatus,
status: body.httpStatus || 200,
json: async () => body,
headers: { get: () => null },
};
},
});
const source = fs.readFileSync(
path.join(__dirname, '../assets/console/console.js'),
'utf8',
);
assert.ok(source.endsWith('})();\n'));
vm.runInContext(
source.slice(0, -6) +
'globalThis.client = { state, nodes, refresh, selectTask, selectTrigger, disconnect };})();',
context,
);
Object.assign(context.client.state, {
token: 'memory-only-test-token',
project: 'default',
view,
});
return { ...context.client, calls };
}
const cursor = (view, row) =>
view === 'runs'
? { runId: row.id, createdAtMs: row.createdAtMs }
: view === 'tasks'
? { taskId: row.taskId }
: { triggerId: row.triggerId };
const rows = (view, count = 65) =>
Array.from({ length: count }, (_, index) => {
const id = `item:${String(
view === 'runs' ? count - index - 1 : index,
).padStart(3, '0')}`;
return {
id,
taskId: id,
triggerId: id,
name: id,
revision: 1,
taskRevision: 1,
kind: 'command',
specSchema: 'qinglong/command@v1',
enabled: true,
status: 'succeeded',
executionOrigin: 'manual',
executionOwner: 'runtime',
createdAtMs: 2000,
updatedAtMs: 2000,
};
});
const page = (view, entries, more = false) => ({
[view]: entries,
[view === 'triggers' ? 'truncated' : 'hasMore']: more,
...(more
? { next: cursor(view, entries.at(-1)) }
: view === 'triggers'
? { next: null }
: {}),
});
const button = (client, label) =>
client.nodes.ledger
.querySelectorAll('button')
.find((entry) => entry.textContent === label);
const records = (client) => client.nodes.ledger.querySelectorAll('.record');
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
for (const view of ['tasks', 'triggers', 'runs']) {
test(`${view}: reaches row 65, refreshes the current cursor and resets without retaining old rows`, async () => {
const entries = rows(view);
const client = fixture(view, (url) =>
url.includes('&after_')
? page(view, entries.slice(64))
: page(view, entries.slice(0, 64), true),
);
await client.refresh();
assert.equal(records(client).length, 64);
const next = button(client, '下一页');
await next.listeners.click();
await next.listeners.click(); // A detached footer cannot issue another request.
assert.equal(records(client).length, 1);
assert.equal(records(client)[0].dataset.identity, entries[64].id);
assert.equal(button(client, '下一页'), undefined);
assert.equal(client.calls.length, 2);
const expected =
view === 'runs'
? 'after_created_at_ms=2000&after_run_id=item:001'
: view === 'tasks'
? 'after_task_id=item:063'
: 'after_trigger_id=item:063';
assert.equal(
client.calls[1].url,
`/api/v3/projects/default/${view}?limit=64&${expected}`,
);
await button(client, '刷新当前页').listeners.click();
assert.equal(client.calls[2].url, client.calls[1].url);
await button(client, '回到首页').listeners.click();
assert.equal(client.calls[3].url, client.calls[0].url);
assert.equal(records(client).length, 64);
assert.equal(client.calls.length, 4);
assert.ok(client.calls.every(({ options }) => options.method === 'GET'));
assert.equal(client.state.selectedId, null);
});
test(`${view}: rejects oversized, unordered, empty-continuation and mismatched cursor responses`, async () => {
const entries = rows(view);
const invalid = [
page(view, entries),
page(view, [entries[1], entries[0]]),
page(view, [entries[0], entries[0]]),
{ [view]: [] },
{
...page(view, [], false),
[view === 'triggers' ? 'truncated' : 'hasMore']: true,
next: cursor(view, entries[0]),
},
{
...page(view, entries.slice(0, 2), true),
next: cursor(view, entries[0]),
},
{
...page(view, entries.slice(0, 2), true),
next: { ...cursor(view, entries[1]), extra: 'not-allowed' },
},
{ ...page(view, entries.slice(0, 2)), next: cursor(view, entries[1]) },
page(view, [
{
...entries[0],
id: 'bad&limit=100',
taskId: 'bad&limit=100',
triggerId: 'bad&limit=100',
},
]),
];
for (const value of invalid) {
const client = fixture(view, () => value);
await client.refresh();
assert.equal(records(client).length, 0);
assert.equal(button(client, '下一页'), undefined);
assert.equal(client.nodes.connection.dataset.state, 'error');
assert.equal(client.calls.length, 1);
}
});
test(`${view}: keeps an empty later page reachable and retries failures at the same boundary`, async () => {
const entries = rows(view);
const client = fixture(view, (_url, call) =>
call === 1
? page(view, entries.slice(0, 64), true)
: call === 2
? { httpStatus: 503, code: 'request_unavailable' }
: page(view, []),
);
await client.refresh();
await button(client, '下一页').listeners.click();
assert.equal(client.nodes.connection.dataset.state, 'error');
await button(client, '刷新当前页').listeners.click();
assert.equal(client.calls[2].url, client.calls[1].url);
assert.equal(records(client).length, 0);
assert.ok(button(client, '回到首页'));
assert.equal(button(client, '下一页'), undefined);
assert.equal(client.calls.length, 3);
});
}
test('coalesces footer clicks and prevents a stale page or error from replacing another view', async () => {
for (const result of [
page('tasks', rows('tasks', 1)),
{ httpStatus: 401, code: 'authentication_required' },
]) {
const pending = deferred();
const client = fixture('tasks', (url, call) =>
call === 1
? page('tasks', rows('tasks').slice(0, 64), true)
: url.includes('/tasks?')
? pending.promise
: page('runs', rows('runs', 1)),
);
await client.refresh();
const next = button(client, '下一页');
const reading = next.listeners.click();
await next.listeners.click();
assert.equal(client.calls.length, 2);
client.state.view = 'runs';
await client.refresh();
const displayed = client.nodes.ledger.textContent;
pending.resolve(result);
await reading;
assert.equal(client.nodes.ledger.textContent, displayed);
assert.equal(client.nodes.connection.dataset.state, 'connected');
assert.equal(client.nodes.refresh.disabled, false);
}
});
test('discarding a response after disconnect/reconnect does not change new session state', async () => {
const old = deferred();
const client = fixture('tasks', (_url, call) =>
call === 1 ? old.promise : page('tasks', rows('tasks', 1)),
);
const reading = client.refresh();
client.disconnect();
const disconnected = client.nodes.ledger.textContent;
old.resolve(page('tasks', rows('tasks')));
await reading;
assert.equal(client.nodes.ledger.textContent, disconnected);
assert.equal(client.state.token, null);
client.state.token = 'new-session-token';
await client.refresh();
assert.equal(records(client).length, 1);
assert.equal(
JSON.stringify(client.state.listRequest).includes('token'),
false,
);
});
test('same-view refresh rejects stale success and leaves current loading state intact', async () => {
const first = deferred();
const second = deferred();
const client = fixture('tasks', (_url, call) =>
call === 1 ? first.promise : second.promise,
);
const old = client.refresh();
const current = client.refresh();
first.resolve(page('tasks', rows('tasks', 1)));
await old;
assert.equal(records(client).length, 0);
assert.equal(client.nodes.refresh.disabled, true);
second.resolve(page('tasks', rows('tasks', 2)));
await current;
assert.equal(records(client).length, 2);
});
test('Task and Trigger details cannot overwrite a later list generation', async () => {
for (const view of ['tasks', 'triggers']) {
const pending = deferred();
const client = fixture(view, (url) =>
url.includes('?') ? page(view, rows(view, 1)) : pending.promise,
);
await client.refresh();
const select = view === 'tasks' ? client.selectTask : client.selectTrigger;
const reading = select('item:000');
await client.refresh();
client.nodes.detail.textContent = 'current detail';
pending.resolve(
view === 'tasks'
? { task: rows(view, 1)[0] }
: { trigger: rows(view, 1)[0] },
);
await reading;
assert.equal(client.nodes.detail.textContent, 'current detail');
}
});
test('stale Secret metadata cannot replace the new ledger or catalog', async () => {
const pending = deferred();
const client = fixture('secrets', (url) =>
url.includes('/secrets?') ? pending.promise : page('tasks', []),
);
const reading = client.refresh();
client.state.view = 'tasks';
await client.refresh();
client.state.secretCatalog = ['current-session-catalog'];
const displayed = client.nodes.ledger.textContent;
pending.resolve({ secrets: [], truncated: false });
await reading;
assert.equal(client.nodes.ledger.textContent, displayed);
assert.deepEqual(client.state.secretCatalog, ['current-session-catalog']);
});
test('generated continuation URLs pass the production HTTP parser for all three ledgers', async (t) => {
const net = require('node:net');
const {
startLocalApiHttpSurface,
} = require('../dist/transport/httpSurface.js');
const port = await new Promise((resolve, reject) => {
const listener = net.createServer();
listener.once('error', reject);
listener.listen(0, '127.0.0.1', () => {
const port = listener.address().port;
listener.close((error) => (error ? reject(error) : resolve(port)));
});
});
const observed = [];
const surface = await startLocalApiHttpSurface({
profile: 'edge',
host: '127.0.0.1',
port,
admission: {
async prepare({ operation }) {
observed.push(operation);
const view = {
'task.list': 'tasks',
'trigger.list': 'triggers',
'run.list': 'runs',
}[operation.operationId];
assert.ok(view);
const input = operation.input || operation;
assert.equal(input.limit, 64);
const entries = rows(view);
if (input.after)
assert.deepEqual(input.after, cursor(view, entries[63]));
return {
bodyMode: 'none',
maximumBodyBytes: 0,
async handle() {
return {
statusCode: 200,
body: input.after
? page(view, entries.slice(64))
: page(view, entries.slice(0, 64), true),
};
},
};
},
},
});
t.after(() => surface.stopAndDrain());
for (const view of ['tasks', 'triggers', 'runs']) {
const client = fixture(view, async (url) => {
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
headers: { authorization: 'Bearer test' },
});
assert.equal(response.status, 200);
return response.json();
});
await client.refresh();
await button(client, '下一页').listeners.click();
assert.equal(records(client).length, 1);
}
assert.equal(observed.length, 6);
});